From 4db494809063c6ec9c64bd94183c9227c115739f Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 05:11:50 +0200 Subject: [PATCH] u --- src/components/article/ArticleView.jsx | 997 +----------------- .../article/boxes/ArticleViewBrand.jsx | 119 +++ .../article/boxes/ArticleViewEANs.jsx | 96 ++ .../article/boxes/ArticleViewLists.jsx | 59 ++ .../article/boxes/ArticleViewName.jsx | 87 ++ .../article/boxes/ArticleViewPrice.jsx | 440 ++++++++ .../article/boxes/ArticleViewStock.jsx | 38 + src/components/article/boxes/PriceTaxRow.jsx | 81 ++ src/components/article/boxes/PriceUnitRow.jsx | 135 +++ .../article/boxes/TierPricePills.jsx | 31 + src/components/tiles/BackTile.jsx | 35 +- src/pages/artikel/CategoryDroppable.js | 113 ++ src/pages/artikel/CategorySortable.js | 114 ++ src/pages/artikel/kategorien.jsx | 243 +---- 14 files changed, 1410 insertions(+), 1178 deletions(-) create mode 100644 src/components/article/boxes/ArticleViewBrand.jsx create mode 100644 src/components/article/boxes/ArticleViewEANs.jsx create mode 100644 src/components/article/boxes/ArticleViewLists.jsx create mode 100644 src/components/article/boxes/ArticleViewName.jsx create mode 100644 src/components/article/boxes/ArticleViewPrice.jsx create mode 100644 src/components/article/boxes/ArticleViewStock.jsx create mode 100644 src/components/article/boxes/PriceTaxRow.jsx create mode 100644 src/components/article/boxes/PriceUnitRow.jsx create mode 100644 src/components/article/boxes/TierPricePills.jsx create mode 100644 src/pages/artikel/CategoryDroppable.js create mode 100644 src/pages/artikel/CategorySortable.js diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 3f3cc79..2f46ab8 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -1,56 +1,9 @@ -import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; -import { useEffect, useState } from 'react'; -import Autocomplete from '@mui/material/Autocomplete'; -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 { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../store/brandSlice'; -import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice'; -import { - articleEans, - buildPosPricesPutBody, - companyTaxRateOptions, - priceBrutto, - primaryPhoto, - primaryPrice, - tierDraftFromArticle, -} from '../../utils/articleDetail'; -import { - formatPrice, - formatPriceAmount, - netFromGross, - parsePriceInput, -} from '../../utils/price'; -import { - findUnitBySi, - firstPackUnitSi, - isNoPackUnit, - unitLabel, -} from '../../utils/units'; -import { notifyError } from '../../utils/notify'; +import { PanelTileGrid } from '../panels'; +import { requireDefined } from '../../utils/apiShape'; +import { primaryPhoto } from '../../utils/articleDetail'; import { PANEL_TILE_KEYS } from '../../theme'; -import { formatStock } from '../../pages/artikel/searchFormat'; -import { artikelDetailPath } from '../../pages/artikel/articleRoute'; import { buildCategoryPath, defaultCategoryPath, @@ -60,524 +13,15 @@ import { removeArticleBySku, selectCategoryRootId, } from '../../store/categorySlice'; -import { selectCurrentCompany } from '../../store/companySlice'; -function PanelValue({ label, children }) { - if (children == null || children === '') return null; - return ( - - {label ? ( - - {label} - - ) : null} - - {children} - - - ); -} - -const headerTileSx = { - minHeight: 0, -}; - -/** Second article row (Bestand / Marke / SKU / Preis). */ -const SECOND_ROW_MIN_HEIGHT = 220; - -function CountLine({ label, count }) { - if (count == null) return null; - return ( - - {label}: {count} - - ); -} - -function RelationLine({ label, active, children }) { - return ( - - {label}:{' '} - {active ? ( - children ?? 'ja' - ) : ( - - nein - - )} - - ); -} - -function nameDraftDirty(draft, saved) { - return draft.name.trim() !== String(saved.name ?? '').trim(); -} - -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 brandDraftValue(article) { - return { name: article?.brand?.name ?? '' }; -} - -function brandDraftDirty(draft, saved) { - return String(draft.name ?? '').trim() !== String(saved.name ?? '').trim(); -} - -function brandDraftValid(draft) { - return String(draft.name ?? '').trim().length > 0; -} - -function eansDraftValue(article) { - return { eans: articleEans(article) }; -} - -function eansDraftDirty(draft, saved) { - return JSON.stringify(draft.eans ?? []) !== JSON.stringify(saved.eans ?? []); -} - -function eansDraftValid(draft) { - return (draft.eans ?? []).every( - (ean) => typeof ean === 'string' && ean.trim().length > 0, - ); -} - -function priceDraftValue(article, company) { - const basePrice = primaryPrice(article); - const gross = priceBrutto(basePrice); - const hasUnit = articleHasPackUnit(article); - return { - gross: gross != null ? formatPriceAmount(gross) : '', - taxRate: - basePrice?.taxRate != null - ? basePrice.taxRate - : company?.taxrate != null - ? company.taxrate - : '', - tiers: tierDraftFromArticle(article), - hasUnit, - unitamount: - hasUnit && article.unitamount != null - ? formatPriceAmount(article.unitamount) - : '', - unitSi: hasUnit ? (article.unit?.si ?? '') : '', - }; -} - -function tiersEqual(a, b) { - return JSON.stringify(a ?? []) === JSON.stringify(b ?? []); -} - -function priceSectionDirty(draft, saved) { - return ( - parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) || - Number(draft.taxRate) !== Number(saved.taxRate) || - !tiersEqual(draft.tiers, saved.tiers) - ); -} - -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 ?? []) { - if (parsePriceInput(tier.gross) == null) return false; - const mult = Number(tier.minmultiplier); - if (!Number.isFinite(mult) || mult <= 1) return false; - } - return true; -} - -const articleNameTextSx = { - fontWeight: 700, - lineHeight: 1.2, -}; - -const articleNameFieldSx = { - '& .MuiInputBase-input': { - fontSize: '2.125rem', - ...articleNameTextSx, - }, -}; - -const articleNameFieldHiddenSx = { - ...articleNameFieldSx, - pointerEvents: 'none', - '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'transparent', - }, - '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { - borderColor: 'transparent', - }, - '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: 'transparent', - }, - '& .MuiInputLabel-root': { - visibility: 'hidden', - }, -}; - -const articlePriceTextSx = { - fontWeight: 700, - lineHeight: 1.1, - letterSpacing: '-0.02em', -}; - -const articlePriceFieldSx = { - flexShrink: 0, - '& .MuiOutlinedInput-root': { - alignItems: 'baseline', - flexShrink: 0, - }, - '& .MuiInputBase-input': { - fontSize: '3.5rem', - py: 0.5, - height: 'auto', - ...articlePriceTextSx, - }, - '& .MuiInputAdornment-root': { - marginRight: 0.75, - height: 'auto', - maxHeight: 'none', - alignItems: 'baseline', - }, - '& .MuiInputAdornment-root .MuiTypography-root': { - fontSize: '2rem', - fontWeight: 700, - lineHeight: 1.1, - }, - // Keep 1px outline in all states so focus/edit doesn't nudge the value. - '& .MuiOutlinedInput-notchedOutline': { - borderWidth: '1px !important', - }, -}; - -const articlePriceFieldHiddenSx = { - ...articlePriceFieldSx, - pointerEvents: 'none', - '& .MuiOutlinedInput-notchedOutline': { - borderWidth: '1px !important', - borderColor: 'transparent', - }, - '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { - borderWidth: '1px !important', - borderColor: 'transparent', - }, - '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderWidth: '1px !important', - borderColor: 'transparent', - }, -}; - -const priceBodySx = { - width: '100%', - display: 'flex', - flexDirection: 'column', - 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, - fontWeight: 600, - '& .MuiChip-label': { px: 1 }, -}; - -const tierPillSx = { - height: 22, - fontSize: 11, - fontWeight: 600, -}; - -function TaxRatePills({ rates, selected, onSelect, readOnly }) { - return ( - - {rates.map((rate) => { - const active = Number(selected) === Number(rate); - return ( - onSelect(rate)} - sx={{ - ...taxPillSx, - // Keep inactive pills in layout so the active one doesn't jump. - visibility: active || !readOnly ? 'visible' : 'hidden', - bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)', - ...(readOnly - ? {} - : { - cursor: 'pointer', - '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' }, - }), - }} - /> - ); - })} - - ); -} - -function PriceTaxRow({ - rates, - selected, - onSelectTax, - readOnly, - onOpenTiers, -}) { - return ( - - - - - - - ); -} - -function TierPricePills({ tiers }) { - return ( - - {(tiers ?? []).map((tier) => { - const gross = parsePriceInput(tier.gross); - if (gross == null) return null; - return ( - - ); - })} - - ); -} - -function PriceAmountField({ - value, - onChange, - inputRef, - readOnly, - error, -}) { - return ( - { - onChange?.(event.target.value); - } - } - fullWidth - error={error} - sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx} - slotProps={{ - input: { - readOnly, - startAdornment: ( - - € - - ), - }, - htmlInput: { - inputMode: 'decimal', - readOnly, - tabIndex: readOnly ? -1 : undefined, - }, - }} - /> - ); -} - -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' }} - /> - - - - - ); -} +import ArticleTileTab from './ArticleTileTab'; +import ArticlePhotoTile from './ArticlePhotoTile'; +import ArticleViewName from './boxes/ArticleViewName'; +import ArticleViewStock from './boxes/ArticleViewStock'; +import ArticleViewBrand from './boxes/ArticleViewBrand'; +import ArticleViewEANs from './boxes/ArticleViewEANs'; +import ArticleViewPrice from './boxes/ArticleViewPrice'; +import ArticleViewLists from './boxes/ArticleViewLists'; export default function ArticleView({ article: articleData, @@ -587,32 +31,8 @@ export default function ArticleView({ }) { 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 brands = useSelector(selectBrands); - const brandsStatus = useSelector(selectBrandsStatus); - const basePrice = primaryPrice(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]); - - useEffect(() => { - if (brandsStatus !== 'idle') return; - void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err)); - }, [dispatch, brandsStatus]); const syncCategoryListPhoto = (next) => { const photo = primaryPhoto(next); @@ -627,125 +47,11 @@ export default function ArticleView({ ); }; - const syncCategoryListPrice = (next) => { - const price = primaryPrice(next); - dispatch( - patchArticleInLists({ - sku: next.sku, - net: price?.net ?? null, - taxRate: price?.taxRate ?? null, - }), - ); - }; - const handleArticleUpdated = (next) => { onArticleUpdated?.(next); syncCategoryListPhoto(next); }; - const handleSaveName = async (draft) => { - const name = draft.name.trim(); - const res = await article.update(articleData.sku, { name }); - requireObject(res, 'PATCH /api/article/:sku'); - requireObject(res.article, 'article'); - onArticleUpdated?.(res.article); - }; - - const handleSaveBrand = async (draft) => { - const name = String(draft.name ?? '').trim(); - if (!name) { - throw new Error('Bitte eine Marke angeben.'); - } - - const existing = brands.find( - (row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(), - ); - - let brandId; - if (existing) { - brandId = existing.id; - } else { - const created = await dispatch(upsertBrand({ name })).unwrap(); - requireDefined(created.id, 'brand.id'); - brandId = created.id; - } - - const res = await article.setBrandIdBySku(articleData.sku, brandId); - requireObject(res, 'PUT /api/article/:sku/brand'); - requireObject(res.article, 'article'); - onArticleUpdated?.(res.article); - }; - - const handleSaveEans = async (draft) => { - const eans = (draft.eans ?? []) - .map((ean) => String(ean).trim()) - .filter((ean) => ean.length > 0); - const res = await article.setEansBySku(articleData.sku, eans); - requireObject(res, 'PUT /api/article/:sku/eans'); - requireObject(res.article, 'article'); - onArticleUpdated?.(res.article); - }; - - const handleSavePrice = async (draft) => { - 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; - } - } - - 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 = () => { const categoryId = requireDefined( articleData.categoryIds?.[0], @@ -761,260 +67,34 @@ export default function ArticleView({ return ( - ( - {}} - sx={articleNameFieldHiddenSx} - /> - )} - renderEdit={(draft, onChange, inputRef) => ( - onChange({ name })} - sx={articleNameFieldSx} - /> - )} + - - - {articleData.stock != null ? formatStock(articleData.stock) : null} - - + - ( - {saved.name || null} - )} - renderEdit={(draft, onChange, inputRef) => ( - - typeof option === 'string' ? option : String(option.name ?? '') - } - onInputChange={(_event, value) => onChange({ name: value })} - onChange={(_event, option) => { - onChange({ name: option ? String(option.name ?? '') : '' }); - }} - renderInput={(params) => ( - - )} - /> - )} + - ( - <> - {articleData.sku} - {(saved.eans ?? []).map((ean) => ( - - {ean} - - ))} - - )} - renderEdit={(draft, onChange, inputRef) => ( - onChange({ eans })} - renderInput={(params) => ( - - )} - /> - )} + - { - 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) => ( - - - - - - - )} - renderEdit={(draft, onChange, inputRef) => { - const draftGross = parsePriceInput(draft.gross); - const draftAmount = parsePriceInput(draft.unitamount); - return ( - - onChange({ ...draft, gross })} - error={draft.gross !== '' && draftGross == null} - /> - onChange({ ...draft, taxRate })} - onOpenTiers={() => 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: '' }); - } - }} - /> - - ); - }} + - - 0} - > - {articleData.childCount} - - - {articleData.fatherSku ? ( - - {articleData.fatherSku} - - ) : null} - - - - - + ); } diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx new file mode 100644 index 0000000..f976f1d --- /dev/null +++ b/src/components/article/boxes/ArticleViewBrand.jsx @@ -0,0 +1,119 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useEffect } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import { EditablePanelTile } from '../../panels'; +import { article } from '../../../api'; +import { requireDefined, requireObject } from '../../../utils/apiShape'; +import { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../../store/brandSlice'; +import { notifyError } from '../../../utils/notify'; +import { PANEL_TILE_KEYS } from '../../../theme'; + +function PanelValue({ label, children }) { + if (children == null || children === '') return null; + return ( + + {label ? ( + + {label} + + ) : null} + + {children} + + + ); +} + +function brandDraftValue(article) { + return { name: article?.brand?.name ?? '' }; +} + +function brandDraftDirty(draft, saved) { + return String(draft.name ?? '').trim() !== String(saved.name ?? '').trim(); +} + +function brandDraftValid(draft) { + return String(draft.name ?? '').trim().length > 0; +} + +const SECOND_ROW_MIN_HEIGHT = 220; + +export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) { + const dispatch = useDispatch(); + const brands = useSelector(selectBrands); + const brandsStatus = useSelector(selectBrandsStatus); + + useEffect(() => { + if (brandsStatus !== 'idle') return; + void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err)); + }, [dispatch, brandsStatus]); + + const handleSave = async (draft) => { + const name = String(draft.name ?? '').trim(); + if (!name) { + throw new Error('Bitte eine Marke angeben.'); + } + + const existing = brands.find( + (row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(), + ); + + let brandId; + if (existing) { + brandId = existing.id; + } else { + const created = await dispatch(upsertBrand({ name })).unwrap(); + requireDefined(created.id, 'brand.id'); + brandId = created.id; + } + + const res = await article.setBrandIdBySku(articleData.sku, brandId); + requireObject(res, 'PUT /api/article/:sku/brand'); + requireObject(res.article, 'article'); + onArticleUpdated?.(res.article); + }; + + return ( + ( + {saved.name || null} + )} + renderEdit={(draft, onChange, inputRef) => ( + + typeof option === 'string' ? option : String(option.name ?? '') + } + onInputChange={(_event, value) => onChange({ name: value })} + onChange={(_event, option) => { + onChange({ name: option ? String(option.name ?? '') : '' }); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx new file mode 100644 index 0000000..026f766 --- /dev/null +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -0,0 +1,96 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import { EditablePanelTile } from '../../panels'; +import { article } from '../../../api'; +import { requireObject } from '../../../utils/apiShape'; +import { articleEans } from '../../../utils/articleDetail'; +import { PANEL_TILE_KEYS } from '../../../theme'; + +function PanelValue({ label, children }) { + if (children == null || children === '') return null; + return ( + + {label ? ( + + {label} + + ) : null} + + {children} + + + ); +} + +function eansDraftValue(article) { + return { eans: articleEans(article) }; +} + +function eansDraftDirty(draft, saved) { + return JSON.stringify(draft.eans ?? []) !== JSON.stringify(saved.eans ?? []); +} + +function eansDraftValid(draft) { + return (draft.eans ?? []).every( + (ean) => typeof ean === 'string' && ean.trim().length > 0, + ); +} + +const SECOND_ROW_MIN_HEIGHT = 220; + +export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) { + const handleSave = async (draft) => { + const eans = (draft.eans ?? []) + .map((ean) => String(ean).trim()) + .filter((ean) => ean.length > 0); + const res = await article.setEansBySku(articleData.sku, eans); + requireObject(res, 'PUT /api/article/:sku/eans'); + requireObject(res.article, 'article'); + onArticleUpdated?.(res.article); + }; + + return ( + ( + <> + {articleData.sku} + {(saved.eans ?? []).map((ean) => ( + + {ean} + + ))} + + )} + renderEdit={(draft, onChange, inputRef) => ( + onChange({ eans })} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx new file mode 100644 index 0000000..cb39a07 --- /dev/null +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -0,0 +1,59 @@ +import { Link as RouterLink } from 'react-router-dom'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import { PanelTile } from '../../panels'; +import { PANEL_TILE_KEYS } from '../../../theme'; +import { artikelDetailPath } from '../../../pages/artikel/articleRoute'; + +function CountLine({ label, count }) { + if (count == null) return null; + return ( + + {label}: {count} + + ); +} + +function RelationLine({ label, active, children }) { + return ( + + {label}:{' '} + {active ? ( + children ?? 'ja' + ) : ( + + nein + + )} + + ); +} + +export default function ArticleViewLists({ article: articleData }) { + return ( + + 0} + > + {articleData.childCount} + + + {articleData.fatherSku ? ( + + {articleData.fatherSku} + + ) : null} + + + + + + ); +} diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx new file mode 100644 index 0000000..9746494 --- /dev/null +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -0,0 +1,87 @@ +import { EditablePanelTile } from '../../panels'; +import SingleLineTextEdit from '../../SingleLineTextEdit'; +import { article } from '../../../api'; +import { requireObject } from '../../../utils/apiShape'; +import { PANEL_TILE_KEYS } from '../../../theme'; + +const articleNameTextSx = { + fontWeight: 700, + lineHeight: 1.2, +}; + +const articleNameFieldSx = { + '& .MuiInputBase-input': { + fontSize: '2.125rem', + ...articleNameTextSx, + }, +}; + +const articleNameFieldHiddenSx = { + ...articleNameFieldSx, + pointerEvents: 'none', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'transparent', + }, + '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'transparent', + }, + '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: 'transparent', + }, + '& .MuiInputLabel-root': { + visibility: 'hidden', + }, +}; + +function nameDraftDirty(draft, saved) { + return draft.name.trim() !== String(saved.name ?? '').trim(); +} + +function nameDraftValid(draft) { + return draft.name.trim().length > 0; +} + +const headerTileSx = { + minHeight: 0, +}; + +export default function ArticleViewName({ article: articleData, onArticleUpdated }) { + const handleSave = async (draft) => { + const name = draft.name.trim(); + const res = await article.update(articleData.sku, { name }); + requireObject(res, 'PATCH /api/article/:sku'); + requireObject(res.article, 'article'); + onArticleUpdated?.(res.article); + }; + + return ( + ( + {}} + sx={articleNameFieldHiddenSx} + /> + )} + renderEdit={(draft, onChange, inputRef) => ( + onChange({ name })} + sx={articleNameFieldSx} + /> + )} + /> + ); +} diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx new file mode 100644 index 0000000..4968429 --- /dev/null +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -0,0 +1,440 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { EditablePanelTile } from '../../panels'; +import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; +import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; +import { article } from '../../../api'; +import { requireDefined, requireObject } from '../../../utils/apiShape'; +import { loadUnits, selectUnits, selectUnitsStatus } from '../../../store/unitSlice'; +import { selectCurrentCompany } from '../../../store/companySlice'; +import { patchArticleInLists } from '../../../store/categorySlice'; +import { + buildPosPricesPutBody, + companyTaxRateOptions, + priceBrutto, + primaryPrice, + tierDraftFromArticle, +} from '../../../utils/articleDetail'; +import { + formatPriceAmount, + netFromGross, + parsePriceInput, +} from '../../../utils/price'; +import { + findUnitBySi, + firstPackUnitSi, + isNoPackUnit, +} from '../../../utils/units'; +import { notifyError } from '../../../utils/notify'; +import { PANEL_TILE_KEYS } from '../../../theme'; +import { PriceTaxRow } from './PriceTaxRow'; +import TierPricePills from './TierPricePills'; +import { PriceUnitRow, PriceUnitCorner } from './PriceUnitRow'; + +const SECOND_ROW_MIN_HEIGHT = 220; + +const articlePriceTextSx = { + fontWeight: 700, + lineHeight: 1.1, + letterSpacing: '-0.02em', +}; + +const articlePriceFieldSx = { + flexShrink: 0, + '& .MuiOutlinedInput-root': { + alignItems: 'baseline', + flexShrink: 0, + }, + '& .MuiInputBase-input': { + fontSize: '3.5rem', + py: 0.5, + height: 'auto', + ...articlePriceTextSx, + }, + '& .MuiInputAdornment-root': { + marginRight: 0.75, + height: 'auto', + maxHeight: 'none', + alignItems: 'baseline', + }, + '& .MuiInputAdornment-root .MuiTypography-root': { + fontSize: '2rem', + fontWeight: 700, + lineHeight: 1.1, + }, + '& .MuiOutlinedInput-notchedOutline': { + borderWidth: '1px !important', + }, +}; + +const articlePriceFieldHiddenSx = { + ...articlePriceFieldSx, + pointerEvents: 'none', + '& .MuiOutlinedInput-notchedOutline': { + borderWidth: '1px !important', + borderColor: 'transparent', + }, + '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { + borderWidth: '1px !important', + borderColor: 'transparent', + }, + '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderWidth: '1px !important', + borderColor: 'transparent', + }, +}; + +const priceBodySx = { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 0.75, +}; + +/* ── helper functions ──────────────────────────────────────────────── */ + +function articleHasPackUnit(art) { + const si = art.unit?.si; + const amount = art.unitamount; + if (si == null || amount == null) return false; + return !isNoPackUnit(si, amount); +} + +function tiersEqual(a, b) { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []); +} + +function priceDraftValue(art, company) { + const basePrice = primaryPrice(art); + const gross = priceBrutto(basePrice); + const hasUnit = articleHasPackUnit(art); + return { + gross: gross != null ? formatPriceAmount(gross) : '', + taxRate: + basePrice?.taxRate != null + ? basePrice.taxRate + : company?.taxrate != null + ? company.taxrate + : '', + tiers: tierDraftFromArticle(art), + hasUnit, + unitamount: + hasUnit && art.unitamount != null + ? formatPriceAmount(art.unitamount) + : '', + unitSi: hasUnit ? (art.unit?.si ?? '') : '', + }; +} + +function priceSectionDirty(draft, saved) { + return ( + parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) || + Number(draft.taxRate) !== Number(saved.taxRate) || + !tiersEqual(draft.tiers, saved.tiers) + ); +} + +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; + 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 ?? []) { + if (parsePriceInput(tier.gross) == null) return false; + const mult = Number(tier.minmultiplier); + if (!Number.isFinite(mult) || mult <= 1) return false; + } + return true; +} + +/* ── sub-component: amount field ──────────────────────────────────── */ + +function PriceAmountField({ + value, + onChange, + inputRef, + readOnly, + error, +}) { + return ( + { + onChange?.(event.target.value); + } + } + fullWidth + error={error} + sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx} + slotProps={{ + input: { + readOnly, + startAdornment: ( + + € + + ), + }, + htmlInput: { + inputMode: 'decimal', + readOnly, + tabIndex: readOnly ? -1 : undefined, + }, + }} + /> + ); +} + +/* ── main component ────────────────────────────────────────────────── */ + +export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) { + const dispatch = useDispatch(); + const [tiersDialogOpen, setTiersDialogOpen] = useState(false); + const [unitEditorOpen, setUnitEditorOpen] = useState(false); + const units = useSelector(selectUnits); + const unitsStatus = useSelector(selectUnitsStatus); + const company = useSelector(selectCurrentCompany); + const basePrice = primaryPrice(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 syncCategoryListPrice = (next) => { + const price = primaryPrice(next); + dispatch( + patchArticleInLists({ + sku: next.sku, + net: price?.net ?? null, + taxRate: price?.taxRate ?? null, + }), + ); + }; + + const handleSave = async (draft) => { + 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; + } + } + + 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); + }; + + return ( + { + 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, + ); + 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) => ( + + + + + + + )} + renderEdit={(draft, onChange, inputRef) => { + const draftGross = parsePriceInput(draft.gross); + const draftAmount = parsePriceInput(draft.unitamount); + return ( + + onChange({ ...draft, gross })} + error={draft.gross !== '' && draftGross == null} + /> + onChange({ ...draft, taxRate })} + onOpenTiers={() => 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/article/boxes/ArticleViewStock.jsx b/src/components/article/boxes/ArticleViewStock.jsx new file mode 100644 index 0000000..404437e --- /dev/null +++ b/src/components/article/boxes/ArticleViewStock.jsx @@ -0,0 +1,38 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { PanelTile } from '../../panels'; +import { PANEL_TILE_KEYS } from '../../../theme'; +import { formatStock } from '../../../pages/artikel/searchFormat'; + +function PanelValue({ label, children }) { + if (children == null || children === '') return null; + return ( + + {label ? ( + + {label} + + ) : null} + + {children} + + + ); +} + +const SECOND_ROW_MIN_HEIGHT = 220; + +export default function ArticleViewStock({ article: articleData }) { + return ( + + + {articleData.stock != null ? formatStock(articleData.stock) : null} + + + ); +} diff --git a/src/components/article/boxes/PriceTaxRow.jsx b/src/components/article/boxes/PriceTaxRow.jsx new file mode 100644 index 0000000..6a959aa --- /dev/null +++ b/src/components/article/boxes/PriceTaxRow.jsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; + +const taxPillSx = { + height: 28, + fontSize: 13, + fontWeight: 600, + '& .MuiChip-label': { px: 1 }, +}; + +export function TaxRatePills({ rates, selected, onSelect, readOnly }) { + return ( + + {rates.map((rate) => { + const active = Number(selected) === Number(rate); + return ( + onSelect(rate)} + sx={{ + ...taxPillSx, + visibility: active || !readOnly ? 'visible' : 'hidden', + bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)', + ...(readOnly + ? {} + : { + cursor: 'pointer', + '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' }, + }), + }} + /> + ); + })} + + ); +} + +export function PriceTaxRow({ + rates, + selected, + onSelectTax, + readOnly, + onOpenTiers, +}) { + return ( + + + + + + + ); +} diff --git a/src/components/article/boxes/PriceUnitRow.jsx b/src/components/article/boxes/PriceUnitRow.jsx new file mode 100644 index 0000000..a61f46f --- /dev/null +++ b/src/components/article/boxes/PriceUnitRow.jsx @@ -0,0 +1,135 @@ +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 InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import { findUnitBySi, unitLabel } from '../../../utils/units'; + +const priceUnitCornerSx = { + position: 'absolute', + top: 72, + right: 4, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 1.25, + zIndex: 2, +}; + +export 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 + + + + + ); +} + +export function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) { + return ( + + onToggleHasUnit(event.target.checked)} + inputProps={{ 'aria-label': 'Einheit' }} + /> + + + + + ); +} diff --git a/src/components/article/boxes/TierPricePills.jsx b/src/components/article/boxes/TierPricePills.jsx new file mode 100644 index 0000000..661e1c5 --- /dev/null +++ b/src/components/article/boxes/TierPricePills.jsx @@ -0,0 +1,31 @@ +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import { formatPrice, parsePriceInput } from '../../../utils/price'; + +const tierPillSx = { + height: 22, + fontSize: 11, + fontWeight: 600, +}; + +export default function TierPricePills({ tiers }) { + return ( + + {(tiers ?? []).map((tier) => { + const gross = parsePriceInput(tier.gross); + if (gross == null) return null; + return ( + + ); + })} + + ); +} diff --git a/src/components/tiles/BackTile.jsx b/src/components/tiles/BackTile.jsx index 1ed6eb7..9689973 100644 --- a/src/components/tiles/BackTile.jsx +++ b/src/components/tiles/BackTile.jsx @@ -1,7 +1,13 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { Tile } from './Tile'; +import { Tile, tileBaseSx } from './Tile'; +/** + * BackTile renders as a
(Box) instead of