This commit is contained in:
seb
2026-08-02 18:54:25 +02:00
parent 780f946eff
commit b787549a85
5 changed files with 346 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
import Box from '@mui/material/Box';
@@ -23,10 +24,10 @@ import ArticleUnitEditorDialog from './ArticleUnitEditorDialog';
import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../store/brandSlice';
import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice';
import {
articleEans,
brandName,
buildPosPricesPutBody,
companyTaxRateOptions,
priceBrutto,
@@ -123,6 +124,32 @@ function articleHasPackUnit(article) {
return !isNoPackUnit(si, amount);
}
function brandDraftValue(article) {
return { name: article?.brand?.name ?? '' };
}
function brandDraftDirty(draft, saved) {
return String(draft.name ?? '').trim() !== String(saved.name ?? '').trim();
}
function brandDraftValid(draft) {
return String(draft.name ?? '').trim().length > 0;
}
function eansDraftValue(article) {
return { eans: articleEans(article) };
}
function eansDraftDirty(draft, saved) {
return JSON.stringify(draft.eans ?? []) !== JSON.stringify(saved.eans ?? []);
}
function eansDraftValid(draft) {
return (draft.eans ?? []).every(
(ean) => typeof ean === 'string' && ean.trim().length > 0,
);
}
function priceDraftValue(article, company) {
const basePrice = primaryPrice(article);
const gross = priceBrutto(basePrice);
@@ -567,8 +594,9 @@ export default function ArticleView({
const company = useSelector(selectCurrentCompany);
const units = useSelector(selectUnits);
const unitsStatus = useSelector(selectUnitsStatus);
const brands = useSelector(selectBrands);
const brandsStatus = useSelector(selectBrandsStatus);
const basePrice = primaryPrice(articleData);
const eans = articleEans(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const priceValue = priceDraftValue(articleData, company);
const unitOptions =
@@ -581,6 +609,11 @@ export default function ArticleView({
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err));
}, [dispatch, unitsStatus]);
useEffect(() => {
if (brandsStatus !== 'idle') return;
void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err));
}, [dispatch, brandsStatus]);
const syncCategoryListPhoto = (next) => {
const photo = primaryPhoto(next);
dispatch(
@@ -618,6 +651,41 @@ export default function ArticleView({
onArticleUpdated?.(res.article);
};
const handleSaveBrand = async (draft) => {
const name = String(draft.name ?? '').trim();
if (!name) {
throw new Error('Bitte eine Marke angeben.');
}
const existing = brands.find(
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
);
let brandId;
if (existing) {
brandId = existing.id;
} else {
const created = await dispatch(upsertBrand({ name })).unwrap();
requireDefined(created.id, 'brand.id');
brandId = created.id;
}
const res = await article.setBrandIdBySku(articleData.sku, brandId);
requireObject(res, 'PUT /api/article/:sku/brand');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
const handleSaveEans = async (draft) => {
const eans = (draft.eans ?? [])
.map((ean) => String(ean).trim())
.filter((ean) => ean.length > 0);
const res = await article.setEansBySku(articleData.sku, eans);
requireObject(res, 'PUT /api/article/:sku/eans');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
const handleSavePrice = async (draft) => {
const saved = priceDraftValue(articleData, company);
let nextArticle = articleData;
@@ -741,28 +809,85 @@ export default function ArticleView({
</PanelValue>
</PanelTile>
<PanelTile
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>{brandName(articleData)}</PanelValue>
</PanelTile>
value={brandDraftValue(articleData)}
onSave={handleSaveBrand}
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 } }}
/>
)}
/>
)}
/>
<PanelTile
<EditablePanelTile
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>
contentAlignItems="flex-start"
value={eansDraftValue(articleData)}
onSave={handleSaveEans}
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 } }}
/>
)}
/>
)}
/>
<EditablePanelTile
wide={1}