This commit is contained in:
seb
2026-07-31 18:20:46 +02:00
parent a6a9777f31
commit ce8d7d04da
4 changed files with 83 additions and 24 deletions

View File

@@ -90,7 +90,11 @@ export const article = {
return http(`/api/article/search?${params}`); return http(`/api/article/search?${params}`);
}, },
/** GET /api/article/:sku → { article: { id, sku, name, slug, net, taxRate, fatherArticleId, fatherSku, … } } */ /**
* GET /api/article/:sku → { article }
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
* (Flat net/taxRate/thumb* only on category tree / getCategoryArticles.)
*/
getBySku(sku) { getBySku(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`); return http(`/api/article/${encodeURIComponent(sku)}`);
}, },

View File

@@ -6,6 +6,12 @@ import Typography from '@mui/material/Typography';
import { article } from '../../api'; import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels'; import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
import {
articleEans,
brandName,
priceBrutto,
primaryPrice,
} from '../../utils/articleDetail';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { formatStock } from '../../pages/artikel/searchFormat'; import { formatStock } from '../../pages/artikel/searchFormat';
@@ -68,11 +74,6 @@ function RelationLine({ label, active, children }) {
); );
} }
function articleBrutto(article) {
if (article.net == null || article.taxRate == null) return null;
return article.net * (1 + article.taxRate / 100);
}
function nameDraftDirty(draft, saved) { function nameDraftDirty(draft, saved) {
return draft.name.trim() !== String(saved.name ?? '').trim(); return draft.name.trim() !== String(saved.name ?? '').trim();
} }
@@ -82,7 +83,9 @@ function nameDraftValid(draft) {
} }
export default function ArticleView({ article: articleData, onArticleUpdated }) { export default function ArticleView({ article: articleData, onArticleUpdated }) {
const brutto = articleBrutto(articleData); const basePrice = primaryPrice(articleData);
const brutto = priceBrutto(basePrice);
const eans = articleEans(articleData);
const handleSaveName = async (draft) => { const handleSaveName = async (draft) => {
const name = draft.name.trim(); const name = draft.name.trim();
@@ -108,7 +111,7 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
minHeight: { xs: 0, sm: 200 }, minHeight: { xs: 0, sm: 200 },
pr: 4, pr: 5,
}} }}
> >
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.25 }}> <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
@@ -153,20 +156,24 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
</PanelTile> </PanelTile>
<PanelTile wide={1} title="Marke"> <PanelTile wide={1} title="Marke">
<PanelValue>{articleData.brand}</PanelValue> <PanelValue>{brandName(articleData)}</PanelValue>
</PanelTile> </PanelTile>
<PanelTile wide={1} title="SKU / EAN"> <PanelTile wide={1} title="SKU / EAN">
<PanelValue label="SKU">{articleData.sku}</PanelValue> <PanelValue label="SKU">{articleData.sku}</PanelValue>
<PanelValue label="EAN">{articleData.ean}</PanelValue> {eans.map((ean) => (
<PanelValue key={ean} label="EAN">
{ean}
</PanelValue>
))}
</PanelTile> </PanelTile>
<PanelTile wide={1} title="Preis"> <PanelTile wide={1} title="Preis">
<PanelValue label="Netto"> <PanelValue label="Netto">
{articleData.net != null ? formatPrice(articleData.net) : null} {basePrice?.net != null ? formatPrice(basePrice.net) : null}
</PanelValue> </PanelValue>
<PanelValue label="MwSt."> <PanelValue label="MwSt.">
{articleData.taxRate != null ? `${articleData.taxRate} %` : null} {basePrice?.taxRate != null ? `${basePrice.taxRate} %` : null}
</PanelValue> </PanelValue>
<PanelValue label="Brutto"> <PanelValue label="Brutto">
{brutto != null ? formatPrice(brutto) : null} {brutto != null ? formatPrice(brutto) : null}

View File

@@ -70,7 +70,7 @@ export class EditablePanelTile extends Component {
}; };
handleCancel = (event) => { handleCancel = (event) => {
event.stopPropagation(); event?.stopPropagation();
this.setState({ this.setState({
editing: false, editing: false,
draft: null, draft: null,
@@ -82,8 +82,25 @@ export class EditablePanelTile extends Component {
this.setState({ draft: nextDraft, error: null }); this.setState({ draft: nextDraft, error: null });
}; };
handleEditKeyDown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.handleCancel();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
if (this.canSave()) {
void this.handleSave(event);
}
}
};
handleSave = async (event) => { handleSave = async (event) => {
event.stopPropagation(); event?.stopPropagation();
if (!this.canSave()) return; if (!this.canSave()) return;
const { onSave } = this.props; const { onSave } = this.props;
@@ -109,7 +126,7 @@ export class EditablePanelTile extends Component {
<Typography <Typography
variant="overline" variant="overline"
color="text.secondary" color="text.secondary"
sx={{ display: 'block', mb: 1, pr: editing ? 7 : 4 }} sx={{ display: 'block', mb: 1, pr: editing ? 9 : 5 }}
> >
{title} {title}
</Typography> </Typography>
@@ -126,16 +143,16 @@ export class EditablePanelTile extends Component {
top: 4, top: 4,
color: 'text.secondary', color: 'text.secondary',
}; };
const iconSx = { fontSize: 28 };
if (!editing) { if (!editing) {
return ( return (
<IconButton <IconButton
size="small"
onClick={this.handleStartEdit} onClick={this.handleStartEdit}
aria-label="Bearbeiten" aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }} sx={{ ...actionSx, right: 4 }}
> >
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon sx={iconSx} />
</IconButton> </IconButton>
); );
} }
@@ -143,16 +160,14 @@ export class EditablePanelTile extends Component {
return ( return (
<> <>
<IconButton <IconButton
size="small"
onClick={this.handleCancel} onClick={this.handleCancel}
disabled={saving} disabled={saving}
aria-label="Abbrechen" aria-label="Abbrechen"
sx={{ ...actionSx, right: 40 }} sx={{ ...actionSx, right: 48 }}
> >
<CloseOutlinedIcon fontSize="small" /> <CloseOutlinedIcon sx={iconSx} />
</IconButton> </IconButton>
<IconButton <IconButton
size="small"
onClick={this.handleSave} onClick={this.handleSave}
disabled={!this.canSave()} disabled={!this.canSave()}
aria-label="Speichern" aria-label="Speichern"
@@ -160,9 +175,9 @@ export class EditablePanelTile extends Component {
sx={{ ...actionSx, right: 4 }} sx={{ ...actionSx, right: 4 }}
> >
{saving ? ( {saving ? (
<CircularProgress size={18} color="inherit" /> <CircularProgress size={22} color="inherit" />
) : ( ) : (
<SaveOutlinedIcon fontSize="small" /> <SaveOutlinedIcon sx={iconSx} />
)} )}
</IconButton> </IconButton>
</> </>
@@ -188,7 +203,10 @@ export class EditablePanelTile extends Component {
> >
{this.renderActions()} {this.renderActions()}
{this.renderTitle()} {this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}> <Box
sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}
onKeyDown={editing ? this.handleEditKeyDown : undefined}
>
{editing && draft != null {editing && draft != null
? renderEdit(draft, this.handleDraftChange, this.inputRef) ? renderEdit(draft, this.handleDraftChange, this.inputRef)
: renderDisplay(savedValue)} : renderDisplay(savedValue)}

View File

@@ -0,0 +1,30 @@
/** Helpers for GET/PATCH /api/article/:sku detail shape. */
export function sortedPhotos(article) {
if (!Array.isArray(article?.photos)) return [];
return [...article.photos].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
}
export function primaryPhoto(article) {
const photos = sortedPhotos(article);
return photos[0] ?? null;
}
export function primaryPrice(article) {
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];
}
export function priceBrutto(price) {
if (price?.net == null || price?.taxRate == null) return null;
return price.net * (1 + price.taxRate / 100);
}
export function brandName(article) {
return article?.brand?.name ?? null;
}
export function articleEans(article) {
if (!Array.isArray(article?.eans)) return [];
return article.eans.filter(Boolean);
}