Use /artikel/:sku/:tab with a localStorage-backed default, and a pin control to set which tab opens first.
932 lines
26 KiB
JavaScript
932 lines
26 KiB
JavaScript
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>
|
||
);
|
||
}
|