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:
seb
2026-08-01 06:37:47 +02:00
parent 1b8652ceaf
commit 80f093649a
7 changed files with 841 additions and 25 deletions

View File

@@ -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 };

View 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);

View File

@@ -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>
);
}}

View File

@@ -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={{

View File

@@ -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
View 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
View 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 '';
}