From 80f093649a7f2251ff3f19001daefbfa29f48048 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 06:37:47 +0200 Subject: [PATCH] Add article pack units with Redux-cached company unit list. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api/index.js | 36 +- .../article/ArticleUnitEditorDialog.jsx | 317 ++++++++++++++++ src/components/article/ArticleView.jsx | 353 ++++++++++++++++-- src/components/panels/EditablePanelTile.jsx | 8 + src/store/index.js | 7 +- src/store/unitSlice.js | 98 +++++ src/utils/units.js | 47 +++ 7 files changed, 841 insertions(+), 25 deletions(-) create mode 100644 src/components/article/ArticleUnitEditorDialog.jsx create mode 100644 src/store/unitSlice.js create mode 100644 src/utils/units.js 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 ( + + Einheiten + + + Typische Einheiten + + + {COMMON_UNITS.map((preset) => ( + this.handlePreset(preset)} + disabled={busy} + /> + ))} + + + + + + Firma + + {loading ? ( + + ) : ( + + {units.map((row) => ( + void this.handleDelete()} + > + + + ) : null + } + > + this.handleSelectUnit(row)} + > + + + + ))} + {units.length === 0 ? ( + + Noch keine Einheiten. + + ) : null} + + )} + + + + + this.handleField('name', e.target.value)} + size="small" + fullWidth + disabled={busy} + slotProps={{ inputLabel: { shrink: true } }} + /> + this.handleField('symbol', e.target.value)} + size="small" + fullWidth + disabled={busy} + slotProps={{ inputLabel: { shrink: true } }} + /> + this.handleField('si', e.target.value)} + size="small" + fullWidth + disabled={busy} + slotProps={{ inputLabel: { shrink: true } }} + /> + this.handleField('basesi', e.target.value)} + size="small" + fullWidth + disabled={busy} + slotProps={{ inputLabel: { shrink: true } }} + /> + 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' }, + }} + /> + + + + + + + + + ); + } +} + +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 ''; +}