diff --git a/src/api/index.js b/src/api/index.js
index 9af8bda..251002f 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -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 };
diff --git a/src/components/article/ArticleUnitEditorDialog.jsx b/src/components/article/ArticleUnitEditorDialog.jsx
new file mode 100644
index 0000000..f4c88a6
--- /dev/null
+++ b/src/components/article/ArticleUnitEditorDialog.jsx
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx
index 7ec7411..266248b 100644
--- a/src/components/article/ArticleView.jsx
+++ b/src/components/article/ArticleView.jsx
@@ -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 (
+
+ {hasUnit && unitamount ? (
+
+ ) : null}
+
+ );
+ }
+
+ if (!hasUnit) {
+ return ;
+ }
+
+ return (
+
+ onChangeAmount(event.target.value)}
+ size="small"
+ error={amountError}
+ sx={{ width: 88, flexShrink: 0 }}
+ slotProps={{
+ inputLabel: { shrink: true },
+ htmlInput: { inputMode: 'decimal' },
+ }}
+ />
+
+
+ Einheit
+
+
+
+
+ );
+}
+
+function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
+ return (
+
+ onToggleHasUnit(event.target.checked)}
+ inputProps={{ 'aria-label': 'Einheit' }}
+ />
+
+
+
+
+ );
+}
+
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 (
+ {
+ 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) => (
@@ -545,10 +819,19 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
readOnly
/>
+
)}
renderEdit={(draft, onChange, inputRef) => {
const draftGross = parsePriceInput(draft.gross);
+ const draftAmount = parsePriceInput(draft.unitamount);
return (
setTiersDialogOpen(true)}
/>
+ onChange({ ...draft, unitamount })}
+ onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
+ amountError={
+ draft.hasUnit &&
+ draft.unitamount !== '' &&
+ (draftAmount == null || draftAmount <= 0)
+ }
+ />
setTiersDialogOpen(false)}
onApply={(tiers) => onChange({ ...draft, tiers })}
/>
+ setUnitEditorOpen(false)}
+ onUnitUpserted={(row) => {
+ if (!draft.unitSi) {
+ onChange({ ...draft, unitSi: row.si });
+ }
+ }}
+ onUnitDeleted={(si) => {
+ if (draft.unitSi === si) {
+ onChange({ ...draft, unitSi: '' });
+ }
+ }}
+ />
);
}}
diff --git a/src/components/panels/EditablePanelTile.jsx b/src/components/panels/EditablePanelTile.jsx
index 53a9e01..d0ce299 100644
--- a/src/components/panels/EditablePanelTile.jsx
+++ b/src/components/panels/EditablePanelTile.jsx
@@ -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()}
(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()
diff --git a/src/store/unitSlice.js b/src/store/unitSlice.js
new file mode 100644
index 0000000..f97f9e8
--- /dev/null
+++ b/src/store/unitSlice.js
@@ -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;
diff --git a/src/utils/units.js b/src/utils/units.js
new file mode 100644
index 0000000..95fec08
--- /dev/null
+++ b/src/utils/units.js
@@ -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 '';
+}