u
This commit is contained in:
File diff suppressed because it is too large
Load Diff
119
src/components/article/boxes/ArticleViewBrand.jsx
Normal file
119
src/components/article/boxes/ArticleViewBrand.jsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
{label ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EditablePanelTile
|
||||||
|
wide={1}
|
||||||
|
tileKey={PANEL_TILE_KEYS.brand}
|
||||||
|
title="Marke"
|
||||||
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
|
value={brandDraftValue(articleData)}
|
||||||
|
onSave={handleSave}
|
||||||
|
isDirty={brandDraftDirty}
|
||||||
|
canSave={brandDraftValid}
|
||||||
|
renderDisplay={(saved) => (
|
||||||
|
<PanelValue>{saved.name || null}</PanelValue>
|
||||||
|
)}
|
||||||
|
renderEdit={(draft, onChange, inputRef) => (
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
freeSolo
|
||||||
|
options={brands}
|
||||||
|
value={draft.name ?? ''}
|
||||||
|
inputValue={draft.name ?? ''}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
typeof option === 'string' ? option : String(option.name ?? '')
|
||||||
|
}
|
||||||
|
onInputChange={(_event, value) => onChange({ name: value })}
|
||||||
|
onChange={(_event, option) => {
|
||||||
|
onChange({ name: option ? String(option.name ?? '') : '' });
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
inputRef={inputRef}
|
||||||
|
label="Marke"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/article/boxes/ArticleViewEANs.jsx
Normal file
96
src/components/article/boxes/ArticleViewEANs.jsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
{label ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EditablePanelTile
|
||||||
|
wide={1}
|
||||||
|
tileKey={PANEL_TILE_KEYS.identifiers}
|
||||||
|
title="SKU / EAN"
|
||||||
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
|
contentAlignItems="flex-start"
|
||||||
|
value={eansDraftValue(articleData)}
|
||||||
|
onSave={handleSave}
|
||||||
|
isDirty={eansDraftDirty}
|
||||||
|
canSave={eansDraftValid}
|
||||||
|
renderDisplay={(saved) => (
|
||||||
|
<>
|
||||||
|
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
||||||
|
{(saved.eans ?? []).map((ean) => (
|
||||||
|
<PanelValue key={ean} label="EAN">
|
||||||
|
{ean}
|
||||||
|
</PanelValue>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
renderEdit={(draft, onChange, inputRef) => (
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
value={draft.eans ?? []}
|
||||||
|
onChange={(_event, eans) => onChange({ eans })}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
inputRef={inputRef}
|
||||||
|
label="EANs (Enter zum Hinzufügen)"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/article/boxes/ArticleViewLists.jsx
Normal file
59
src/components/article/boxes/ArticleViewLists.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleViewLists({ article: articleData }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/article/boxes/ArticleViewName.jsx
Normal file
87
src/components/article/boxes/ArticleViewName.jsx
Normal file
@@ -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 (
|
||||||
|
<EditablePanelTile
|
||||||
|
wide={3}
|
||||||
|
tileKey={PANEL_TILE_KEYS.name}
|
||||||
|
minHeight={0}
|
||||||
|
sx={headerTileSx}
|
||||||
|
value={{ name: articleData.name ?? '' }}
|
||||||
|
onSave={handleSave}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
440
src/components/article/boxes/ArticleViewPrice.jsx
Normal file
440
src/components/article/boxes/ArticleViewPrice.jsx
Normal file
@@ -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 (
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<EditablePanelTile
|
||||||
|
wide={1}
|
||||||
|
tileKey={PANEL_TILE_KEYS.price}
|
||||||
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
|
contentAlignItems="flex-start"
|
||||||
|
value={priceValue}
|
||||||
|
onSave={handleSave}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/article/boxes/ArticleViewStock.jsx
Normal file
38
src/components/article/boxes/ArticleViewStock.jsx
Normal file
@@ -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 (
|
||||||
|
<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 SECOND_ROW_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
|
export default function ArticleViewStock({ article: articleData }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/article/boxes/PriceTaxRow.jsx
Normal file
81
src/components/article/boxes/PriceTaxRow.jsx
Normal file
@@ -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 (
|
||||||
|
<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,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/article/boxes/PriceUnitRow.jsx
Normal file
135
src/components/article/boxes/PriceUnitRow.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/article/boxes/TierPricePills.jsx
Normal file
31
src/components/article/boxes/TierPricePills.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { Tile } from './Tile';
|
import { Tile, tileBaseSx } from './Tile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackTile renders as a <div> (Box) instead of <button> (ButtonBase)
|
||||||
|
* so it is not affected by body.tree-dragging button { pointer-events: none }
|
||||||
|
* and can serve as a Droppable dropzone.
|
||||||
|
*/
|
||||||
export class BackTile extends Tile {
|
export class BackTile extends Tile {
|
||||||
renderIcon() {
|
renderIcon() {
|
||||||
return <ArrowBackIcon color="action" sx={{ fontSize: 36 }} />;
|
return <ArrowBackIcon color="action" sx={{ fontSize: 36 }} />;
|
||||||
@@ -15,4 +21,31 @@ export class BackTile extends Tile {
|
|||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { sx, onClick: _onClick, label: _label, ...rest } = this.props;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="div"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
sx={{
|
||||||
|
...tileBaseSx,
|
||||||
|
position: 'relative',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.renderIcon()}
|
||||||
|
{this.renderLabel()}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/pages/artikel/CategoryDroppable.js
Normal file
113
src/pages/artikel/CategoryDroppable.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Droppable } from '@shopify/draggable';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates @shopify/draggable Droppable logic for moving a category
|
||||||
|
* into another category (tree drag-and-drop).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* this.droppable = new CategoryDroppable(containerEl, { onMove });
|
||||||
|
* this.droppable.activate();
|
||||||
|
* this.droppable.destroy();
|
||||||
|
*/
|
||||||
|
export default class CategoryDroppable {
|
||||||
|
#instance = null;
|
||||||
|
#container = null;
|
||||||
|
#onMove = null;
|
||||||
|
#pendingDropId = null;
|
||||||
|
#dropTargetEl = null;
|
||||||
|
|
||||||
|
constructor(container, { onMove }) {
|
||||||
|
this.#container = container;
|
||||||
|
this.#onMove = onMove;
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
const el = this.#container;
|
||||||
|
if (!el || this.#instance) return;
|
||||||
|
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
this.#instance = new Droppable(el, {
|
||||||
|
draggable: '.sortable-item',
|
||||||
|
dropzone: '.sortable-item',
|
||||||
|
handle: '.droppable-handle',
|
||||||
|
delay: 0,
|
||||||
|
mirror: { constrainDimensions: true },
|
||||||
|
classes: { 'droppable:active': '', 'droppable:occupied': '' },
|
||||||
|
});
|
||||||
|
this.#instance.on('drag:start', this.#handleTreeDragStart);
|
||||||
|
this.#instance.on('drag:over', this.#handleDropOver);
|
||||||
|
this.#instance.on('drag:out', this.#handleDropOut);
|
||||||
|
this.#instance.on('droppable:dropped', this.#handleDropped);
|
||||||
|
this.#instance.on('droppable:returned', this.#handleDroppedReturned);
|
||||||
|
this.#instance.on('drag:stop', this.#handleDragStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.#instance) {
|
||||||
|
this.#instance.destroy();
|
||||||
|
this.#instance = null;
|
||||||
|
}
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
this.#clearDropTarget();
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- private ---
|
||||||
|
|
||||||
|
#handleTreeDragStart = () => {
|
||||||
|
document.body.classList.add('tree-dragging');
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleDropped = (event) => {
|
||||||
|
event.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleDropOver = (event) => {
|
||||||
|
const tile =
|
||||||
|
event?.over && typeof event.over.closest === 'function'
|
||||||
|
? event.over.closest('.sortable-item')
|
||||||
|
: null;
|
||||||
|
if (!tile) return;
|
||||||
|
const sourceId = Number(event?.source?.dataset?.id);
|
||||||
|
const targetId = Number(tile.dataset.id);
|
||||||
|
if (sourceId && targetId === sourceId) {
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#clearDropTarget();
|
||||||
|
this.#pendingDropId = targetId;
|
||||||
|
this.#dropTargetEl = tile;
|
||||||
|
tile.classList.add('tree-drop-target');
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleDropOut = () => {
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
this.#clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleDroppedReturned = (event) => {
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
this.#clearDropTarget();
|
||||||
|
event.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleDragStop = (event) => {
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
const categoryId = Number(event?.source?.dataset?.id);
|
||||||
|
const targetId = this.#pendingDropId;
|
||||||
|
this.#pendingDropId = null;
|
||||||
|
this.#clearDropTarget();
|
||||||
|
if (!categoryId || !targetId || categoryId === targetId) return;
|
||||||
|
void this.#onMove({ categoryId, parentId: targetId })
|
||||||
|
.unwrap()
|
||||||
|
.catch(notifyError);
|
||||||
|
};
|
||||||
|
|
||||||
|
#clearDropTarget = () => {
|
||||||
|
if (this.#dropTargetEl) {
|
||||||
|
this.#dropTargetEl.classList.remove('tree-drop-target');
|
||||||
|
this.#dropTargetEl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/pages/artikel/CategorySortable.js
Normal file
114
src/pages/artikel/CategorySortable.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Sortable from 'sortablejs';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates SortableJS logic for reordering category tiles inside a grid.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* this.sortable = new CategorySortable(containerEl, { onReorder });
|
||||||
|
* this.sortable.activate();
|
||||||
|
* this.sortable.destroy();
|
||||||
|
* this.sortable.sortableIds(); // current DOM order
|
||||||
|
* this.sortable.handleMoveCategory(id, direction); // +1 / -1
|
||||||
|
*/
|
||||||
|
export default class CategorySortable {
|
||||||
|
#instance = null;
|
||||||
|
#container = null;
|
||||||
|
#onReorder = null;
|
||||||
|
#checkpointOrder = null;
|
||||||
|
|
||||||
|
constructor(container, { onReorder }) {
|
||||||
|
this.#container = container;
|
||||||
|
this.#onReorder = onReorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
const el = this.#container;
|
||||||
|
if (!el || this.#instance) return;
|
||||||
|
|
||||||
|
this.#instance = Sortable.create(el, {
|
||||||
|
draggable: '.sortable-item:not(.not-sortable)',
|
||||||
|
handle: '.sortable-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
dragClass: 'sortable-drag',
|
||||||
|
filter: '.not-sortable, .sortable-handle-btn',
|
||||||
|
forceFallback: true,
|
||||||
|
fallbackOnBody: true,
|
||||||
|
onStart: this.#handleSortStart,
|
||||||
|
onEnd: this.#handleSort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.#instance) {
|
||||||
|
this.#instance.destroy();
|
||||||
|
this.#instance = null;
|
||||||
|
}
|
||||||
|
this.#checkpointOrder = null;
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
sortableIds() {
|
||||||
|
const container = this.#container;
|
||||||
|
if (!container) return [];
|
||||||
|
return [...container.querySelectorAll('.sortable-item:not(.not-sortable)')]
|
||||||
|
.map((el) => el.dataset.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveCategory = (categoryId, direction) => {
|
||||||
|
const ids = this.sortableIds();
|
||||||
|
const index = ids.indexOf(categoryId);
|
||||||
|
if (index === -1) return;
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= ids.length) return;
|
||||||
|
const next = [...ids];
|
||||||
|
next[index] = ids[target];
|
||||||
|
next[target] = categoryId;
|
||||||
|
void this.#onReorder({ categoryIds: next }).unwrap().catch(notifyError);
|
||||||
|
};
|
||||||
|
|
||||||
|
revertDomOrder = (order) => {
|
||||||
|
const container = this.#container;
|
||||||
|
if (!container) return;
|
||||||
|
const items = [...container.querySelectorAll('.sortable-item')];
|
||||||
|
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
|
||||||
|
const anchor =
|
||||||
|
[...container.children].find((el) => !el.classList.contains('sortable-item')) ?? null;
|
||||||
|
for (const id of order) {
|
||||||
|
const el = byId.get(id);
|
||||||
|
if (!el) continue;
|
||||||
|
if (anchor) container.insertBefore(el, anchor);
|
||||||
|
else container.appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- private ---
|
||||||
|
|
||||||
|
#handleSortStart = () => {
|
||||||
|
this.#checkpointOrder = this.sortableIds();
|
||||||
|
document.body.classList.add('tree-dragging');
|
||||||
|
};
|
||||||
|
|
||||||
|
#handleSort = () => {
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
const ids = this.sortableIds();
|
||||||
|
const before = this.#checkpointOrder ?? [];
|
||||||
|
this.#checkpointOrder = null;
|
||||||
|
if (
|
||||||
|
before.length === 0 ||
|
||||||
|
before.length !== ids.length ||
|
||||||
|
before.every((id, index) => id === ids[index])
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.#onReorder({ categoryIds: ids })
|
||||||
|
.unwrap()
|
||||||
|
.catch((err) => {
|
||||||
|
notifyError(err);
|
||||||
|
this.revertDomOrder(before);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import Sortable from 'sortablejs';
|
|
||||||
import { Droppable } from '@shopify/draggable';
|
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
|
import CategorySortable from './CategorySortable';
|
||||||
|
import CategoryDroppable from './CategoryDroppable';
|
||||||
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
||||||
import { NewArticleTile } from '../../components/article';
|
import { NewArticleTile } from '../../components/article';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -58,11 +58,8 @@ class ArtikelKategorienPage extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = { drafts: {} };
|
this.state = { drafts: {} };
|
||||||
this.gridRef = createRef();
|
this.gridRef = createRef();
|
||||||
this.sortable = null;
|
this.sortableModule = null;
|
||||||
this.droppable = null;
|
this.droppableModule = null;
|
||||||
this.checkpointOrder = null;
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.dropTargetEl = null;
|
|
||||||
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
||||||
this.handleDraftChange = this.handleDraftChange.bind(this);
|
this.handleDraftChange = this.handleDraftChange.bind(this);
|
||||||
}
|
}
|
||||||
@@ -106,219 +103,47 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.destroySortable();
|
this.sortableModule?.destroy();
|
||||||
this.destroyDroppable();
|
this.droppableModule?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ensureModules = () => {
|
||||||
|
const el = this.gridRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!this.sortableModule) {
|
||||||
|
this.sortableModule = new CategorySortable(el, {
|
||||||
|
onReorder: this.props.reorderCategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!this.droppableModule) {
|
||||||
|
this.droppableModule = new CategoryDroppable(el, {
|
||||||
|
onMove: this.props.moveCategory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
syncSortable = () => {
|
syncSortable = () => {
|
||||||
|
this.#ensureModules();
|
||||||
if (this.props.renameMode) {
|
if (this.props.renameMode) {
|
||||||
// Erst alte Instanz sicher los, falls irgendwann noch vorhanden,
|
this.sortableModule?.destroy();
|
||||||
// damit Sortable die aktuelle (Edit-)DOM-Struktur bindet.
|
this.sortableModule?.activate();
|
||||||
this.destroySortable();
|
|
||||||
this.activateSortable();
|
|
||||||
} else {
|
} else {
|
||||||
this.destroySortable();
|
this.sortableModule?.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncDroppable = () => {
|
syncDroppable = () => {
|
||||||
|
this.#ensureModules();
|
||||||
if (this.props.renameMode) {
|
if (this.props.renameMode) {
|
||||||
this.destroyDroppable();
|
this.droppableModule?.destroy();
|
||||||
this.activateDroppable();
|
this.droppableModule?.activate();
|
||||||
} else {
|
} else {
|
||||||
this.destroyDroppable();
|
this.droppableModule?.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
activateDroppable = () => {
|
|
||||||
const el = this.gridRef.current;
|
|
||||||
if (!el || this.droppable) return;
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.droppable = new Droppable(el, {
|
|
||||||
draggable: '.sortable-item',
|
|
||||||
dropzone: '.sortable-item',
|
|
||||||
handle: '.droppable-handle',
|
|
||||||
delay: 0,
|
|
||||||
mirror: { constrainDimensions: true },
|
|
||||||
// Droppable markiert sonst alle Dropzones gleichzeitig mit der
|
|
||||||
// --active-Klasse. Das deaktivieren wir und heben stattdessen nur das
|
|
||||||
// aktuell anvisierte Ziel über tree-drop-target hervor.
|
|
||||||
classes: { 'droppable:active': '', 'droppable:occupied': '' },
|
|
||||||
});
|
|
||||||
this.droppable.on('drag:start', this.handleTreeDragStart);
|
|
||||||
this.droppable.on('drag:over', this.handleDropOver);
|
|
||||||
this.droppable.on('drag:out', this.handleDropOut);
|
|
||||||
this.droppable.on('droppable:dropped', this.handleDropped);
|
|
||||||
this.droppable.on('droppable:returned', this.handleDroppedReturned);
|
|
||||||
this.droppable.on('drag:stop', this.handleDragStop);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTreeDragStart = () => {
|
|
||||||
// Während des Tree-Draggings native Textselektion unterbinden, damit der
|
|
||||||
// Cursor kein Text-I-Cursor wird und Textareas keine Hover-Selektion zeigen.
|
|
||||||
document.body.classList.add('tree-dragging');
|
|
||||||
};
|
|
||||||
|
|
||||||
destroyDroppable = () => {
|
|
||||||
if (this.droppable) {
|
|
||||||
this.droppable.destroy();
|
|
||||||
this.droppable = null;
|
|
||||||
}
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.clearDropTarget();
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDropped = (event) => {
|
|
||||||
// Reiner DOM-Schutz: verhindert, dass Droppable das Quell-Element per
|
|
||||||
// appendChild in die Dropzone verschiebt. Das Ziel-Tracking läuft über
|
|
||||||
// drag:over/drag:out (pendingDropId), nicht über dieses Event.
|
|
||||||
event.cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDropOver = (event) => {
|
|
||||||
const tile =
|
|
||||||
event?.over && typeof event.over.closest === 'function'
|
|
||||||
? event.over.closest('.sortable-item')
|
|
||||||
: null;
|
|
||||||
if (!tile) return;
|
|
||||||
// Die gezogene Kachel selbst ist kein gültiges Drop-Ziel.
|
|
||||||
const sourceId = Number(event?.source?.dataset?.id);
|
|
||||||
const targetId = Number(tile.dataset.id);
|
|
||||||
if (sourceId && targetId === sourceId) {
|
|
||||||
this.pendingDropId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clearDropTarget();
|
|
||||||
this.pendingDropId = targetId;
|
|
||||||
this.dropTargetEl = tile;
|
|
||||||
tile.classList.add('tree-drop-target');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDropOut = () => {
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.clearDropTarget();
|
|
||||||
};
|
|
||||||
|
|
||||||
clearDropTarget = () => {
|
|
||||||
if (this.dropTargetEl) {
|
|
||||||
this.dropTargetEl.classList.remove('tree-drop-target');
|
|
||||||
this.dropTargetEl = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDroppedReturned = (event) => {
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.clearDropTarget();
|
|
||||||
event.cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDragStop = (event) => {
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
const categoryId = Number(event?.source?.dataset?.id);
|
|
||||||
const targetId = this.pendingDropId;
|
|
||||||
this.pendingDropId = null;
|
|
||||||
this.clearDropTarget();
|
|
||||||
if (!categoryId || !targetId || categoryId === targetId) return;
|
|
||||||
void this.props
|
|
||||||
.moveCategory({ categoryId, parentId: targetId })
|
|
||||||
.unwrap()
|
|
||||||
.catch(notifyError);
|
|
||||||
};
|
|
||||||
|
|
||||||
activateSortable = () => {
|
|
||||||
const el = this.gridRef.current;
|
|
||||||
if (!el || this.sortable) return;
|
|
||||||
this.sortable = Sortable.create(el, {
|
|
||||||
draggable: '.sortable-item',
|
|
||||||
handle: '.sortable-handle',
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'sortable-ghost',
|
|
||||||
dragClass: 'sortable-drag',
|
|
||||||
filter: '.not-sortable, .sortable-handle-btn',
|
|
||||||
// Eigener Fallback-Drag statt nativer HTML5-DnD: sonst zeigt der Browser
|
|
||||||
// über Button/Input den Text-I-Cursor bzw. native Hover-Stile.
|
|
||||||
forceFallback: true,
|
|
||||||
fallbackOnBody: true,
|
|
||||||
onStart: this.handleSortStart,
|
|
||||||
onEnd: this.handleSort,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
destroySortable = () => {
|
|
||||||
if (this.sortable) {
|
|
||||||
this.sortable.destroy();
|
|
||||||
this.sortable = null;
|
|
||||||
}
|
|
||||||
this.checkpointOrder = null;
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
};
|
|
||||||
|
|
||||||
sortableIds = () => {
|
|
||||||
const container = this.gridRef.current;
|
|
||||||
if (!container) return [];
|
|
||||||
return [...container.querySelectorAll('.sortable-item')]
|
|
||||||
.map((el) => el.dataset.id)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(Number);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSortStart = () => {
|
|
||||||
// Snapshot der Reihenfolge vor dem ersten Drag.
|
|
||||||
this.checkpointOrder = this.sortableIds();
|
|
||||||
document.body.classList.add('tree-dragging');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSort = () => {
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
const ids = this.sortableIds();
|
|
||||||
const before = this.checkpointOrder ?? [];
|
|
||||||
this.checkpointOrder = null;
|
|
||||||
if (
|
|
||||||
before.length === 0 ||
|
|
||||||
before.length !== ids.length ||
|
|
||||||
before.every((id, index) => id === ids[index])
|
|
||||||
) {
|
|
||||||
return; // nichts geändert
|
|
||||||
}
|
|
||||||
void this.props
|
|
||||||
.reorderCategories({ categoryIds: ids })
|
|
||||||
.unwrap()
|
|
||||||
.catch((err) => {
|
|
||||||
notifyError(err);
|
|
||||||
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
|
|
||||||
// die Sortable-Instanz bleibt dadurch funktionsfähig.
|
|
||||||
this.revertDomOrder(before);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveCategory = (category, direction) => {
|
handleMoveCategory = (category, direction) => {
|
||||||
const ids = this.sortableIds();
|
this.sortableModule?.handleMoveCategory(category.id, direction);
|
||||||
const index = ids.indexOf(category.id);
|
|
||||||
if (index === -1) return;
|
|
||||||
const target = index + direction;
|
|
||||||
if (target < 0 || target >= ids.length) return; // nicht verschiebbar
|
|
||||||
const next = [...ids];
|
|
||||||
next[index] = ids[target];
|
|
||||||
next[target] = category.id;
|
|
||||||
void this.props.reorderCategories({ categoryIds: next }).unwrap().catch(notifyError);
|
|
||||||
};
|
|
||||||
|
|
||||||
revertDomOrder = (order) => {
|
|
||||||
const container = this.gridRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const items = [...container.querySelectorAll('.sortable-item')];
|
|
||||||
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
|
|
||||||
// Erste nicht-sortierbare Kachel (= BackTile o. Ä.), davor werden die Items eingefügt.
|
|
||||||
const anchor =
|
|
||||||
[...container.children].find((el) => !el.classList.contains('sortable-item')) ??
|
|
||||||
null;
|
|
||||||
for (const id of order) {
|
|
||||||
const el = byId.get(id);
|
|
||||||
if (!el) continue;
|
|
||||||
if (anchor) container.insertBefore(el, anchor);
|
|
||||||
else container.appendChild(el);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ensureTreeLoaded = () => {
|
ensureTreeLoaded = () => {
|
||||||
@@ -495,7 +320,11 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
<Box ref={this.gridRef} sx={gridSx}>
|
<Box ref={this.gridRef} sx={gridSx}>
|
||||||
{canGoBack && (
|
{canGoBack && (
|
||||||
<BackTile onClick={this.handleNavigateBack} />
|
<BackTile
|
||||||
|
onClick={this.handleNavigateBack}
|
||||||
|
data-id={String(breadcrumbs[breadcrumbs.length - 2].id)}
|
||||||
|
className="sortable-item not-sortable"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{childCategories.map((item, index) => {
|
{childCategories.map((item, index) => {
|
||||||
@@ -524,7 +353,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}}
|
}}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
size="small"
|
size="small"
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
slotProps={{ inputLabel: { shrink: true }, input: { spellCheck: false } }}
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
'& .MuiInput-underline:before, & .MuiInput-underline:after': {
|
'& .MuiInput-underline:before, & .MuiInput-underline:after': {
|
||||||
|
|||||||
Reference in New Issue
Block a user