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