This commit is contained in:
seb
2026-08-03 05:11:50 +02:00
parent 5b5c77f870
commit 4db4948090
14 changed files with 1410 additions and 1178 deletions

File diff suppressed because it is too large Load Diff

View 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 } }}
/>
)}
/>
)}
/>
);
}

View 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 } }}
/>
)}
/>
)}
/>
);
}

View 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>
);
}

View 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}
/>
)}
/>
);
}

View 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>
);
}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}