Files
dashfront/src/components/article/ArticleView.jsx
seb 780f946eff Route article detail tabs in the URL and pin a preferred default.
Use /artikel/:sku/:tab with a localStorage-backed default, and a pin control to set which tab opens first.
2026-08-01 06:45:25 +02:00

932 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
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,
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 { PANEL_TILE_KEYS } from '../../theme';
import { formatStock } from '../../pages/artikel/searchFormat';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import {
patchArticleInLists,
removeArticleBySku,
selectCategoryRootId,
} from '../../store/categorySlice';
import { selectCurrentCompany } from '../../store/companySlice';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
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 (
<Typography variant="body2">
{label}: {count}
</Typography>
);
}
function RelationLine({ label, active, children }) {
return (
<Typography variant="body2" sx={{ mb: 0.5 }}>
{label}:{' '}
{active ? (
children ?? 'ja'
) : (
<Typography component="span" variant="body2" color="text.secondary">
nein
</Typography>
)}
</Typography>
);
}
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 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 (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
{rates.map((rate) => {
const active = Number(selected) === Number(rate);
return (
<Chip
key={rate}
size="small"
label={`${rate} %`}
onClick={readOnly || !onSelect ? undefined : () => 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)' },
}),
}}
/>
);
})}
</Box>
);
}
function PriceTaxRow({
rates,
selected,
onSelectTax,
readOnly,
onOpenTiers,
}) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexWrap: 'wrap',
minHeight: 36,
}}
>
<TaxRatePills
rates={rates}
selected={selected}
onSelect={onSelectTax}
readOnly={readOnly}
/>
<IconButton
type="button"
size="small"
aria-label="Staffelpreise"
disabled={readOnly}
onClick={readOnly ? undefined : onOpenTiers}
sx={{
p: 0.5,
visibility: readOnly ? 'hidden' : 'visible',
pointerEvents: readOnly ? 'none' : 'auto',
}}
>
<LayersOutlinedIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
);
}
function TierPricePills({ tiers }) {
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
{(tiers ?? []).map((tier) => {
const gross = parsePriceInput(tier.gross);
if (gross == null) return null;
return (
<Chip
key={tier.minmultiplier}
size="small"
label={`ab ${tier.minmultiplier} · ${formatPrice(gross)}`}
sx={{
...tierPillSx,
bgcolor: 'rgba(0, 0, 0, 0.08)',
}}
/>
);
})}
</Box>
);
}
function PriceAmountField({
value,
onChange,
inputRef,
readOnly,
error,
}) {
return (
<TextField
inputRef={inputRef}
value={value}
onChange={
readOnly
? undefined
: (event) => {
onChange?.(event.target.value);
}
}
fullWidth
error={error}
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
slotProps={{
input: {
readOnly,
startAdornment: (
<InputAdornment position="start">
<Typography component="span">€</Typography>
</InputAdornment>
),
},
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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minHeight: 36 }}>
{hasUnit && unitamount ? (
<Chip
size="medium"
label={`${unitamount} ${symbol}`}
sx={{
height: 28,
fontSize: 13,
fontWeight: 600,
bgcolor: 'rgba(0, 0, 0, 0.08)',
}}
/>
) : null}
</Box>
);
}
if (!hasUnit) {
return <Box sx={{ minHeight: 40 }} />;
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'nowrap',
minHeight: 40,
minWidth: 0,
width: '100%',
}}
>
<TextField
label="Menge"
value={unitamount}
onChange={(event) => onChangeAmount(event.target.value)}
size="small"
error={amountError}
sx={{ width: 88, flexShrink: 0 }}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { inputMode: 'decimal' },
}}
/>
<FormControl size="small" sx={{ flex: 1, minWidth: 0, maxWidth: 160 }}>
<InputLabel id="article-unit-si-label" shrink>
Einheit
</InputLabel>
<Select
labelId="article-unit-si-label"
label="Einheit"
value={unitSi}
displayEmpty
notched
onChange={(event) => onChangeSi(event.target.value)}
sx={{
'& .MuiSelect-select': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
>
{units.map((row) => (
<MenuItem key={row.si} value={row.si}>
{unitLabel(row)}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
return (
<Box sx={priceUnitCornerSx}>
<Switch
size="small"
checked={Boolean(hasUnit)}
onChange={(event) => onToggleHasUnit(event.target.checked)}
inputProps={{ 'aria-label': 'Einheit' }}
/>
<IconButton
type="button"
size="small"
aria-label="Einheiten bearbeiten"
onClick={onOpenEditor}
sx={{ p: 0.5 }}
>
<StraightenOutlinedIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
);
}
export default function ArticleView({
article: articleData,
tab,
onTabChange,
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);
dispatch(
patchArticleInLists({
sku: next.sku,
fullHash: photo?.fullHash ?? null,
thumbW: photo?.thumbW ?? null,
thumbH: photo?.thumbH ?? null,
thumbHash: photo?.thumbHash ?? null,
}),
);
};
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 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],
'article.categoryIds[0]',
);
dispatch(removeArticleBySku(articleData.sku));
if (categories && rootCategoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return;
}
navigate(defaultCategoryPath());
};
return (
<PanelTileGrid>
<EditablePanelTile
wide={3}
tileKey={PANEL_TILE_KEYS.name}
minHeight={0}
sx={headerTileSx}
value={{ name: articleData.name ?? '' }}
onSave={handleSaveName}
isDirty={nameDraftDirty}
canSave={nameDraftValid}
renderDisplay={({ name }) => (
<SingleLineTextEdit
label="Name"
value={name}
readOnly
onChange={() => {}}
sx={articleNameFieldHiddenSx}
/>
)}
renderEdit={(draft, onChange, inputRef) => (
<SingleLineTextEdit
inputRef={inputRef}
label="Name"
value={draft.name}
onChange={(name) => onChange({ name })}
sx={articleNameFieldSx}
/>
)}
/>
<ArticlePhotoTile
wide={1}
tileKey={PANEL_TILE_KEYS.photo}
sx={headerTileSx}
article={articleData}
onArticleUpdated={handleArticleUpdated}
/>
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.stock}
title="Bestand"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>
{articleData.stock != null ? formatStock(articleData.stock) : null}
</PanelValue>
</PanelTile>
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>{brandName(articleData)}</PanelValue>
</PanelTile>
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue label="SKU">{articleData.sku}</PanelValue>
{eans.map((ean) => (
<PanelValue key={ean} label="EAN">
{ean}
</PanelValue>
))}
</PanelTile>
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.price}
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={priceValue}
onSave={handleSavePrice}
isDirty={priceDraftDirty}
canSave={priceDraftValid}
renderOverlay={({ editing, draft, onChange }) => {
if (!editing || draft == null) return null;
return (
<PriceUnitCorner
hasUnit={draft.hasUnit}
onToggleHasUnit={(hasUnit) => {
if (hasUnit) {
const unitamount = draft.unitamount || '1';
const amount = parsePriceInput(unitamount) ?? 1;
let unitSi = firstPackUnitSi(
unitOptions,
draft.unitSi || articleData.unit?.si,
);
// Only pc available: keep pc but clear amount so user must set ≠ 1.
if (!unitSi && isNoPackUnit('pc', amount)) {
onChange({
...draft,
hasUnit: true,
unitSi: 'pc',
unitamount: '',
});
return;
}
if (!unitSi) unitSi = 'pc';
onChange({
...draft,
hasUnit: true,
unitSi,
unitamount,
});
return;
}
onChange({
...draft,
hasUnit: false,
unitamount: '',
unitSi: '',
});
}}
onOpenEditor={() => setUnitEditorOpen(true)}
/>
);
}}
renderDisplay={(saved) => (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow
rates={taxRates}
selected={saved.taxRate}
readOnly
/>
<TierPricePills tiers={saved.tiers} />
<PriceUnitRow
hasUnit={saved.hasUnit}
unitamount={saved.unitamount}
unitSi={saved.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
readOnly
/>
</Box>
)}
renderEdit={(draft, onChange, inputRef) => {
const draftGross = parsePriceInput(draft.gross);
const draftAmount = parsePriceInput(draft.unitamount);
return (
<Box sx={priceBodySx}>
<PriceAmountField
inputRef={inputRef}
value={draft.gross}
onChange={(gross) => onChange({ ...draft, gross })}
error={draft.gross !== '' && draftGross == null}
/>
<PriceTaxRow
rates={taxRates}
selected={draft.taxRate}
onSelectTax={(taxRate) => onChange({ ...draft, taxRate })}
onOpenTiers={() => setTiersDialogOpen(true)}
/>
<TierPricePills tiers={draft.tiers} />
<PriceUnitRow
hasUnit={draft.hasUnit}
unitamount={draft.unitamount}
unitSi={draft.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
onChangeAmount={(unitamount) => onChange({ ...draft, unitamount })}
onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
amountError={
draft.hasUnit &&
draft.unitamount !== '' &&
(draftAmount == null || draftAmount <= 0)
}
/>
<ArticlePriceTiersDialog
open={tiersDialogOpen}
tiers={draft.tiers ?? []}
onClose={() => setTiersDialogOpen(false)}
onApply={(tiers) => onChange({ ...draft, tiers })}
/>
<ArticleUnitEditorDialog
open={unitEditorOpen}
onClose={() => setUnitEditorOpen(false)}
onUnitUpserted={(row) => {
if (!draft.unitSi) {
onChange({ ...draft, unitSi: row.si });
}
}}
onUnitDeleted={(si) => {
if (draft.unitSi === si) {
onChange({ ...draft, unitSi: '' });
}
}}
/>
</Box>
);
}}
/>
<ArticleTileTab
wide={3}
tileKey={PANEL_TILE_KEYS.content}
minHeight={200}
article={articleData}
tab={tab}
onTabChange={onTabChange}
onArticleDeleted={handleArticleDeleted}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
<RelationLine
label="Vaterartikel"
active={articleData.childCount > 0}
>
{articleData.childCount}
</RelationLine>
<RelationLine
label="Kindartikel"
active={articleData.fatherArticleId != null}
>
{articleData.fatherSku ? (
<Link
component={RouterLink}
to={artikelDetailPath(articleData.fatherSku)}
>
{articleData.fatherSku}
</Link>
) : null}
</RelationLine>
<CountLine label="Optionen" count={articleData.optionsCount} />
<CountLine label="Stücklisten" count={articleData.articleListCount} />
<CountLine label="Varianten" count={articleData.childCount} />
</PanelTile>
</PanelTileGrid>
);
}