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
|
* PATCH /api/article/:sku
|
||||||
* Body: partial fields, e.g. { name }
|
* Body: at least one of { name, unitamount, unitSi }
|
||||||
* → 200 { article }
|
* unitSi must exist for selected company → 404 Unit not found
|
||||||
|
* → 200 { article } (same detail shape as GET)
|
||||||
*/
|
*/
|
||||||
update(sku, body) {
|
update(sku, body) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
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 = {
|
export const category = {
|
||||||
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
|
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
|
||||||
getTree() {
|
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 { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
||||||
|
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import Link from '@mui/material/Link';
|
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 TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||||
import ArticlePriceTiersDialog from './ArticlePriceTiersDialog';
|
import ArticlePriceTiersDialog from './ArticlePriceTiersDialog';
|
||||||
import ArticleTileTab from './ArticleTileTab';
|
import ArticleTileTab from './ArticleTileTab';
|
||||||
|
import ArticleUnitEditorDialog from './ArticleUnitEditorDialog';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
|
import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice';
|
||||||
import {
|
import {
|
||||||
articleEans,
|
articleEans,
|
||||||
brandName,
|
brandName,
|
||||||
@@ -32,6 +40,13 @@ import {
|
|||||||
netFromGross,
|
netFromGross,
|
||||||
parsePriceInput,
|
parsePriceInput,
|
||||||
} from '../../utils/price';
|
} from '../../utils/price';
|
||||||
|
import {
|
||||||
|
findUnitBySi,
|
||||||
|
firstPackUnitSi,
|
||||||
|
isNoPackUnit,
|
||||||
|
unitLabel,
|
||||||
|
} from '../../utils/units';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
import { PANEL_TILE_KEYS } from '../../theme';
|
import { PANEL_TILE_KEYS } from '../../theme';
|
||||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +81,7 @@ const headerTileSx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Second article row (Bestand / Marke / SKU / Preis). */
|
/** Second article row (Bestand / Marke / SKU / Preis). */
|
||||||
const SECOND_ROW_MIN_HEIGHT = 176;
|
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
function CountLine({ label, count }) {
|
function CountLine({ label, count }) {
|
||||||
if (count == null) return null;
|
if (count == null) return null;
|
||||||
@@ -100,9 +115,17 @@ function nameDraftValid(draft) {
|
|||||||
return draft.name.trim().length > 0;
|
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) {
|
function priceDraftValue(article, company) {
|
||||||
const basePrice = primaryPrice(article);
|
const basePrice = primaryPrice(article);
|
||||||
const gross = priceBrutto(basePrice);
|
const gross = priceBrutto(basePrice);
|
||||||
|
const hasUnit = articleHasPackUnit(article);
|
||||||
return {
|
return {
|
||||||
gross: gross != null ? formatPriceAmount(gross) : '',
|
gross: gross != null ? formatPriceAmount(gross) : '',
|
||||||
taxRate:
|
taxRate:
|
||||||
@@ -112,6 +135,12 @@ function priceDraftValue(article, company) {
|
|||||||
? company.taxrate
|
? company.taxrate
|
||||||
: '',
|
: '',
|
||||||
tiers: tierDraftFromArticle(article),
|
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 ?? []);
|
return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceDraftDirty(draft, saved) {
|
function priceSectionDirty(draft, saved) {
|
||||||
return (
|
return (
|
||||||
parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) ||
|
parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) ||
|
||||||
Number(draft.taxRate) !== Number(saved.taxRate) ||
|
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) {
|
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 (parsePriceInput(draft.gross) == null) return false;
|
||||||
if (draft.taxRate === '' || draft.taxRate == null) return false;
|
if (draft.taxRate === '' || draft.taxRate == null) return false;
|
||||||
for (const tier of draft.tiers ?? []) {
|
for (const tier of draft.tiers ?? []) {
|
||||||
@@ -174,8 +229,10 @@ const articlePriceTextSx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const articlePriceFieldSx = {
|
const articlePriceFieldSx = {
|
||||||
|
flexShrink: 0,
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
alignItems: 'baseline',
|
alignItems: 'baseline',
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
'& .MuiInputBase-input': {
|
'& .MuiInputBase-input': {
|
||||||
fontSize: '3.5rem',
|
fontSize: '3.5rem',
|
||||||
@@ -224,6 +281,17 @@ const priceBodySx = {
|
|||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const priceUnitCornerSx = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 72,
|
||||||
|
right: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
zIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
const taxPillSx = {
|
const taxPillSx = {
|
||||||
height: 28,
|
height: 28,
|
||||||
fontSize: 13,
|
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 }) {
|
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
||||||
|
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
|
||||||
const categories = useSelector((state) => state.category.categories);
|
const categories = useSelector((state) => state.category.categories);
|
||||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||||
const company = useSelector(selectCurrentCompany);
|
const company = useSelector(selectCurrentCompany);
|
||||||
|
const units = useSelector(selectUnits);
|
||||||
|
const unitsStatus = useSelector(selectUnitsStatus);
|
||||||
const basePrice = primaryPrice(articleData);
|
const basePrice = primaryPrice(articleData);
|
||||||
const eans = articleEans(articleData);
|
const eans = articleEans(articleData);
|
||||||
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
||||||
const priceValue = priceDraftValue(articleData, company);
|
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 syncCategoryListPhoto = (next) => {
|
||||||
const photo = primaryPhoto(next);
|
const photo = primaryPhoto(next);
|
||||||
@@ -420,25 +613,63 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePrice = async (draft) => {
|
const handleSavePrice = async (draft) => {
|
||||||
const gross = requireDefined(parsePriceInput(draft.gross), 'price.gross');
|
const saved = priceDraftValue(articleData, company);
|
||||||
const taxRate = requireDefined(
|
let nextArticle = articleData;
|
||||||
draft.taxRate === '' ? null : Number(draft.taxRate),
|
|
||||||
'price.tax',
|
if (unitSectionDirty(draft, saved)) {
|
||||||
);
|
if (draft.hasUnit) {
|
||||||
if (!Number.isFinite(taxRate)) {
|
const unitamount = requireDefined(
|
||||||
throw new Error('Ungültiger Steuersatz');
|
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({
|
if (priceSectionDirty(draft, saved) && draft.gross !== '') {
|
||||||
net,
|
const gross = requireDefined(parsePriceInput(draft.gross), 'price.gross');
|
||||||
taxRate,
|
const taxRate = requireDefined(
|
||||||
tiers: draft.tiers ?? [],
|
draft.taxRate === '' ? null : Number(draft.taxRate),
|
||||||
});
|
'price.tax',
|
||||||
const res = await article.setPrices(articleData.sku, prices);
|
);
|
||||||
requireObject(res, 'PUT /api/article/:sku/prices');
|
if (!Number.isFinite(taxRate)) {
|
||||||
requireObject(res.article, 'article');
|
throw new Error('Ungültiger Steuersatz');
|
||||||
onArticleUpdated?.(res.article);
|
}
|
||||||
syncCategoryListPrice(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');
|
||||||
|
nextArticle = res.article;
|
||||||
|
syncCategoryListPrice(res.article);
|
||||||
|
}
|
||||||
|
|
||||||
|
onArticleUpdated?.(nextArticle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArticleDeleted = () => {
|
const handleArticleDeleted = () => {
|
||||||
@@ -536,6 +767,49 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
onSave={handleSavePrice}
|
onSave={handleSavePrice}
|
||||||
isDirty={priceDraftDirty}
|
isDirty={priceDraftDirty}
|
||||||
canSave={priceDraftValid}
|
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) => (
|
renderDisplay={(saved) => (
|
||||||
<Box sx={priceBodySx}>
|
<Box sx={priceBodySx}>
|
||||||
<PriceAmountField value={saved.gross} readOnly />
|
<PriceAmountField value={saved.gross} readOnly />
|
||||||
@@ -545,10 +819,19 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<TierPricePills tiers={saved.tiers} />
|
<TierPricePills tiers={saved.tiers} />
|
||||||
|
<PriceUnitRow
|
||||||
|
hasUnit={saved.hasUnit}
|
||||||
|
unitamount={saved.unitamount}
|
||||||
|
unitSi={saved.unitSi}
|
||||||
|
units={unitOptions}
|
||||||
|
displaySymbol={articleData.unit?.symbol}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
renderEdit={(draft, onChange, inputRef) => {
|
renderEdit={(draft, onChange, inputRef) => {
|
||||||
const draftGross = parsePriceInput(draft.gross);
|
const draftGross = parsePriceInput(draft.gross);
|
||||||
|
const draftAmount = parsePriceInput(draft.unitamount);
|
||||||
return (
|
return (
|
||||||
<Box sx={priceBodySx}>
|
<Box sx={priceBodySx}>
|
||||||
<PriceAmountField
|
<PriceAmountField
|
||||||
@@ -564,12 +847,40 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
onOpenTiers={() => setTiersDialogOpen(true)}
|
onOpenTiers={() => setTiersDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
<TierPricePills tiers={draft.tiers} />
|
<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
|
<ArticlePriceTiersDialog
|
||||||
open={tiersDialogOpen}
|
open={tiersDialogOpen}
|
||||||
tiers={draft.tiers ?? []}
|
tiers={draft.tiers ?? []}
|
||||||
onClose={() => setTiersDialogOpen(false)}
|
onClose={() => setTiersDialogOpen(false)}
|
||||||
onApply={(tiers) => onChange({ ...draft, tiers })}
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ class EditablePanelTileBase extends Component {
|
|||||||
tileKey,
|
tileKey,
|
||||||
renderDisplay,
|
renderDisplay,
|
||||||
renderEdit,
|
renderEdit,
|
||||||
|
renderOverlay,
|
||||||
contentAlignItems = 'center',
|
contentAlignItems = 'center',
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { editing, draft, error } = this.state;
|
const { editing, draft, error } = this.state;
|
||||||
@@ -224,6 +225,13 @@ class EditablePanelTileBase extends Component {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{this.renderActions()}
|
{this.renderActions()}
|
||||||
|
{typeof renderOverlay === 'function'
|
||||||
|
? renderOverlay({
|
||||||
|
editing,
|
||||||
|
draft,
|
||||||
|
onChange: this.handleDraftChange,
|
||||||
|
})
|
||||||
|
: null}
|
||||||
{this.renderTitle()}
|
{this.renderTitle()}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
|||||||
import authReducer from './authSlice';
|
import authReducer from './authSlice';
|
||||||
import categoryReducer from './categorySlice';
|
import categoryReducer from './categorySlice';
|
||||||
import companyReducer, { companyLoadListener } from './companySlice';
|
import companyReducer, { companyLoadListener } from './companySlice';
|
||||||
|
import unitReducer, { unitLoadListener } from './unitSlice';
|
||||||
|
|
||||||
const listenerMiddleware = createListenerMiddleware();
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
companyLoadListener(listenerMiddleware);
|
companyLoadListener(listenerMiddleware);
|
||||||
|
unitLoadListener(listenerMiddleware);
|
||||||
|
|
||||||
function reduxDevLogger() {
|
function reduxDevLogger() {
|
||||||
return (storeApi) => (next) => (action) => {
|
return (storeApi) => (next) => (action) => {
|
||||||
@@ -12,7 +14,8 @@ function reduxDevLogger() {
|
|||||||
!import.meta.env.DEV ||
|
!import.meta.env.DEV ||
|
||||||
(!action.type?.startsWith('auth/') &&
|
(!action.type?.startsWith('auth/') &&
|
||||||
!action.type?.startsWith('company/') &&
|
!action.type?.startsWith('company/') &&
|
||||||
!action.type?.startsWith('category/'))
|
!action.type?.startsWith('category/') &&
|
||||||
|
!action.type?.startsWith('unit/'))
|
||||||
) {
|
) {
|
||||||
return next(action);
|
return next(action);
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ function reduxDevLogger() {
|
|||||||
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
||||||
},
|
},
|
||||||
company: after.company.current,
|
company: after.company.current,
|
||||||
|
units: after.unit.units.length,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -37,6 +41,7 @@ export const store = configureStore({
|
|||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
company: companyReducer,
|
company: companyReducer,
|
||||||
category: categoryReducer,
|
category: categoryReducer,
|
||||||
|
unit: unitReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
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