Files
dashfront/src/components/manifest/ManifestFormLine.jsx
2026-10-17 17:43:03 +02:00

1304 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
import { getFirstVal, parseNum, LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice, grossFromNet } from '../../utils/price';
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import { artikelDetailPath } from '../article/articleRoute';
import { BrandLabel } from '../brand';
import { articleSupplierAutomat } from '../../automats/articleDetailAutomat';
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
import {
BORDER,
} from '../../colors';
class ManifestFormLine extends PureComponent {
constructor(props) {
super(props);
const sku = this.getArticleSku(props);
this.state = {
cachedArticle: sku ? getArticle(sku) : null,
loadingArticle: false,
fallbackSuppliers: [],
editingField: null,
};
this.handleRemove = this.handleRemove.bind(this);
this.handleMoveUp = this.handleMoveUp.bind(this);
this.handleMoveDown = this.handleMoveDown.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEdit = this.handleStartEdit.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
}
getArticleSku(props = this.props) {
if (props.kind === 'delivery') {
return props.supplierArticle?.article?.sku || null;
}
return props.line?.sku ? String(props.line.sku).trim() : null;
}
componentDidMount() {
this.subscribeArticle(this.getArticleSku());
}
componentDidUpdate(prevProps) {
const prevSku = this.getArticleSku(prevProps);
const nextSku = this.getArticleSku(this.props);
if (prevSku !== nextSku) {
this.subscribeArticle(nextSku);
}
}
componentWillUnmount() {
this.unsubArticle?.();
this.unsubSupplier?.();
}
subscribeArticle(sku) {
this.unsubArticle?.();
this.unsubArticle = null;
this.unsubSupplier?.();
this.unsubSupplier = null;
if (!sku) {
if (this.state.cachedArticle || this.state.loadingArticle || this.state.fallbackSuppliers?.length) {
this.setState({ cachedArticle: null, loadingArticle: false, fallbackSuppliers: [] });
}
return;
}
const current = getArticle(sku);
if (current) {
this.setState({ cachedArticle: current, loadingArticle: false });
} else {
this.setState({ cachedArticle: null, loadingArticle: true });
artikelAutomat.actions.loadArticle(sku)
.then((art) => {
if (this.getArticleSku() === sku) {
this.setState({ cachedArticle: art, loadingArticle: false });
}
})
.catch(() => {
if (this.getArticleSku() === sku) {
this.setState({ cachedArticle: null, loadingArticle: false });
}
});
}
this.unsubArticle = artikelAutomat.subscribe(this, (state) => {
const art = createArticleSelector(sku)(state);
return {
cachedArticle: art ?? null,
...(art ? { loadingArticle: false } : {}),
};
});
// Fallback: subscribe to articleSupplierAutomat if cachedArticle doesn't have suppliers
if (!current?.suppliers?.length) {
const supKey = articleSupplierAutomat.key(sku);
this.unsubSupplier = supKey.subscribe(this, (item) => ({
fallbackSuppliers: Array.isArray(item?.data) ? item.data : [],
}));
void articleSupplierAutomat.load(sku).catch(() => { });
}
}
handleRemove() {
if (this.props.isOnShipment) return;
this.props.onRemove(this.props.line.key);
}
handleMoveUp() {
this.props.onMove?.(this.props.line.key, -1);
}
handleMoveDown() {
this.props.onMove?.(this.props.line.key, 1);
}
handleStartEdit(columnId) {
const { saving } = this.props;
const isDisabled = saving || (columnId === 'sku' && Boolean(this.props.isOnShipment));
if (isDisabled) return;
if (this.state.editingField !== columnId) {
this.setState({ editingField: columnId });
}
}
handleKeyDown(e) {
if (e.key === 'Enter' || e.key === 'Escape') {
e.target.blur();
}
}
handleBlur(e, columnId) {
if (this.state.editingField === columnId) {
this.setState({ editingField: null });
}
if (columnId === 'sku' && this.props.onSkuBlur) {
this.props.onSkuBlur(this.props.line.key, e.target.value);
}
}
handleCreateArticle() {
const { line, onCreateArticle } = this.props;
if (onCreateArticle) {
onCreateArticle(line);
}
}
handleSearchArticle() {
const { line, onSearchArticle } = this.props;
if (onSearchArticle) {
onSearchArticle(line);
}
}
handleRemoveSupplierArticle() {
const { line, supplierArticle, onRemoveSupplierArticle } = this.props;
if (onRemoveSupplierArticle) {
onRemoveSupplierArticle(line, supplierArticle);
}
}
renderLedgerChip() {
const { ledgerLine, onBookLine } = this.props;
if (!ledgerLine) return null;
const isBooked = ledgerLine.isBooked;
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
const isClickable = Boolean(onBookLine);
const isTracked = Boolean(ledgerLine.trackStock);
const tooltipTitle = isTracked
? isBooked
? `Vollständig im Lager gebucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
: isPartial
? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
: `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
: isBooked
? `Vollständig im Ledger verbucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
: isPartial
? `Teilweise im Ledger verbucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Verbuchen dieser Position)`
: `Noch nicht im Ledger verbucht (${ledgerLine.targetAmount} Stück – Klicken zum Verbuchen dieser Position)`;
const label = isTracked
? isBooked
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
: isPartial
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
: 'Lager: offen'
: isBooked
? `Gebucht: ${ledgerLine.bookedAmount} Stk.`
: isPartial
? `Gebucht: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
: null;
if (!label) return null;
return (
<Tooltip title={tooltipTitle}>
<Chip
size="small"
clickable={isClickable}
onClick={
isClickable
? (e) => {
e.stopPropagation();
onBookLine(ledgerLine);
}
: undefined
}
icon={
isBooked ? (
<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 13, color: 'inherit' } }} />
) : (
<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 13, color: 'inherit' } }} />
)
}
label={label}
variant={isBooked ? 'outlined' : 'filled'}
sx={{
height: 22,
fontSize: '0.7rem',
fontWeight: 700,
cursor: isClickable ? 'pointer' : 'default',
...(isBooked
? {
bgcolor: 'rgba(21, 128, 61, 0.08)',
color: '#15803d',
borderColor: '#15803d',
borderWidth: 1,
borderStyle: 'solid',
'&:hover': isClickable ? { bgcolor: 'rgba(21, 128, 61, 0.16)' } : undefined,
}
: isPartial
? {
bgcolor: '#d97706',
color: '#ffffff',
border: 'none',
'&:hover': isClickable ? { bgcolor: '#b45309' } : undefined,
}
: {
bgcolor: 'primary.main',
color: '#ffffff',
border: 'none',
'&:hover': isClickable ? { bgcolor: 'primary.dark' } : undefined,
}),
'& .MuiChip-label': {
color: 'inherit',
px: 0.75,
},
'& .MuiChip-icon': {
color: 'inherit',
},
}}
/>
</Tooltip>
);
}
renderField(column, colIdx, totalCols) {
const { id, align, type } = column;
const { line, saving, onChange, columns } = this.props;
const isFirstLine = Boolean(this.props.isFirstLine ?? (this.props.index === 0));
const isLastLine = Boolean(this.props.isLastLine);
if (id === 'pos') {
const posVal =
this.props.positionIndex ??
(this.props.index != null ? this.props.index + 1 : colIdx + 1);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: isFirstLine ? 40 : 41,
minHeight: isFirstLine ? 40 : 41,
maxHeight: isFirstLine ? 40 : 41,
mt: isFirstLine ? 0 : '-1px',
fontWeight: 700,
fontSize: '0.82rem',
color: 'text.secondary',
userSelect: 'none',
}}
>
{posVal}
</Box>
);
}
let val = line[id];
if (val == null || val === '') {
if (id === 'name' && this.state.cachedArticle?.name) {
val = this.state.cachedArticle.name;
} else if (id === 'tax' || id === 'taxrate') {
val = line.taxrate ?? line.tax ?? '';
} else if (id === 'net' || id === 'netprice') {
val = line.netprice ?? line.net ?? '';
} else if (id === 'gross') {
const netNum = parseNum(line.netprice ?? line.net);
const taxNum = parseNum(line.taxrate ?? line.tax) ?? 0;
const rebateNum = parseNum(line.rebate) ?? 0;
if (netNum != null) {
val = String(grossFromNet(netNum, taxNum, rebateNum));
}
}
}
let isError = false;
if (id === 'name') {
isError = !String(line.name ?? '').trim() && !String(line.sku ?? '').trim();
} else if (id === 'amount') {
const raw = getFirstVal(line.amount, line.count);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'pu') {
const raw = getFirstVal(line.pu, line.vpe);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'tax' || id === 'taxrate') {
const raw = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'net' || id === 'netprice') {
const raw = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'gross') {
const raw = getFirstVal(line.gross, val);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'sku') {
isError = !String(line.sku ?? '').trim() && !String(line.name ?? '').trim();
}
const hasPosCol = columns?.[0]?.id === 'pos';
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
const isLast = colIdx === totalCols - 1;
const topLeftRadius = isFirstInput && isFirstLine ? 4 : 0;
const topRightRadius = isLast && isFirstLine ? 4 : 0;
const bottomRightRadius = isLast && isLastLine ? 4 : 0;
const bottomLeftRadius = isFirstInput && isLastLine ? 4 : 0;
const borderRadius = `${topLeftRadius}px ${topRightRadius}px ${bottomRightRadius}px ${bottomLeftRadius}px`;
const { dirtyFields } = this.props;
const isFieldDirty = Boolean(
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
);
const isEditing = this.state.editingField === id;
const isDisabled = saving || (id === 'sku' && Boolean(this.props.isOnShipment));
const titleText = id === 'sku' && this.props.isOnShipment
? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden'
: undefined;
if (isEditing) {
return (
<TextField
autoFocus
size="small"
type={type}
value={val ?? ''}
error={isError}
disabled={isDisabled}
title={titleText}
autoComplete="off"
onChange={(e) => onChange(line.key, id, e.target.value)}
onBlur={(e) => this.handleBlur(e, id)}
onKeyDown={this.handleKeyDown}
slotProps={{
htmlInput: {
inputMode: type === 'number' ? 'decimal' : 'text',
style: { textAlign: align },
autoComplete: 'off',
step: type === 'number' ? 'any' : undefined,
},
}}
sx={{
position: 'relative',
ml: isFirstInput ? 0 : '-1px',
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
mt: isFirstLine ? 0 : '-1px',
height: isFirstLine ? 40 : 41,
zIndex: 3,
'& .MuiOutlinedInput-root': {
height: isFirstLine ? 40 : 41,
borderRadius,
width: '100%',
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
}),
'&:hover': {
zIndex: 4,
},
'&.Mui-focused': {
zIndex: 5,
},
},
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.23)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'primary.main',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.87)',
},
'& input[type=number]': {
MozAppearance: 'textfield',
},
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
{
WebkitAppearance: 'none',
margin: 0,
},
}}
/>
);
}
const hasValue = val != null && String(val).trim() !== '';
return (
<Box
tabIndex={isDisabled ? -1 : 0}
role={isDisabled ? undefined : 'button'}
onClick={() => this.handleStartEdit(id)}
onFocus={() => this.handleStartEdit(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleStartEdit(id);
}
}}
title={titleText}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start',
height: isFirstLine ? 40 : 41,
minHeight: isFirstLine ? 40 : 41,
maxHeight: isFirstLine ? 40 : 41,
boxSizing: 'border-box',
position: 'relative',
ml: isFirstInput ? 0 : '-1px',
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
mt: isFirstLine ? 0 : '-1px',
px: '14px',
py: 0,
borderRadius,
border: '1px solid',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.23)',
cursor: isDisabled ? 'default' : 'text',
touchAction: 'manipulation',
userSelect: 'none',
zIndex: 1,
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
zIndex: 2,
}),
...(isError && !isFieldDirty && {
bgcolor: 'rgba(211, 47, 47, 0.04)',
borderColor: 'error.main',
zIndex: 2,
}),
'&:hover': isDisabled
? undefined
: {
bgcolor: isFieldDirty
? 'rgba(237, 108, 2, 0.14)'
: isError
? 'rgba(211, 47, 47, 0.08)'
: 'action.hover',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.87)',
zIndex: 3,
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: -2,
zIndex: 4,
},
}}
>
<Typography
variant="body2"
sx={{
textAlign: align,
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.875rem',
lineHeight: '20px',
fontWeight: id === 'sku' ? 600 : 400,
color: isError
? 'error.main'
: hasValue
? 'text.primary'
: 'text.disabled',
fontStyle: 'normal',
}}
>
{hasValue ? String(val) : '—'}
</Typography>
</Box>
);
}
renderSecondLine() {
const { kind = 'delivery' } = this.props;
const rawSku = this.props.line?.sku != null ? String(this.props.line.sku).trim() : '';
if (kind === 'delivery') {
return this.renderDeliverySecondLine(rawSku);
}
return this.renderArticleSecondLine(rawSku);
}
renderArticleSecondLine(rawSku) {
const { loading, kind, columns } = this.props;
const isOrder = kind === 'order';
const hasNameColumn = columns?.some((c) => c.id === 'name');
const { cachedArticle, loadingArticle } = this.state;
if (loading || loadingArticle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.25 }}>
<CircularProgress size={14} thickness={5} sx={{ color: 'primary.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.72rem' }}>
Artikeldaten werden geladen…
</Typography>
</Box>
);
}
if (cachedArticle) {
const photo = cachedArticle.photos?.[0] || cachedArticle.articlepic?.[0];
const fullHash = photo?.hash || photo?.fullHash;
const thumbHash = photo?.thumbhash || photo?.thumbHash;
const thumbW = photo?.thumbw || photo?.thumbW;
const thumbH = photo?.thumbh || photo?.thumbH;
const hasPhoto = fullHash != null || thumbHash != null;
const eans = cachedArticle.eans ?? cachedArticle.articleean ?? [];
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
const vkPrice = primaryPrice(cachedArticle);
const vkNetto =
vkPrice?.net != null
? Number(vkPrice.net)
: cachedArticle.net != null
? Number(cachedArticle.net)
: cachedArticle.price != null
? Number(cachedArticle.price)
: null;
const vkTaxRate =
vkPrice?.taxRate ??
vkPrice?.taxrate ??
cachedArticle.taxrate ??
cachedArticle.taxRate ??
selectCompanyTaxrate() ??
19;
const vkBrutto = vkPrice
? priceBrutto(vkPrice) ??
(vkNetto != null && vkTaxRate != null
? Math.round((vkNetto * (1 + Number(vkTaxRate) / 100) + Number.EPSILON) * 100) / 100
: null)
: vkNetto != null
? Math.round((vkNetto * (1 + Number(vkTaxRate) / 100) + Number.EPSILON) * 100) / 100
: null;
const suppliers =
Array.isArray(cachedArticle.suppliers) && cachedArticle.suppliers.length > 0
? cachedArticle.suppliers
: (this.state.fallbackSuppliers ?? []);
const topSupplier =
suppliers.find((s) => s.sort === 0) ??
(suppliers.length > 0
? [...suppliers].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0))[0]
: null);
const ekNetto =
topSupplier?.price?.net != null
? Number(topSupplier.price.net)
: topSupplier?.netPrice != null
? Number(topSupplier.netPrice)
: topSupplier?.net != null
? Number(topSupplier.net)
: null;
const ekTaxRate =
topSupplier?.price?.taxRate != null
? Number(topSupplier.price.taxRate)
: topSupplier?.taxRate != null
? Number(topSupplier.taxRate)
: parseNum(this.props.line?.taxrate ?? this.props.line?.tax) ?? selectCompanyTaxrate() ?? 19;
const ekBrutto =
ekNetto != null
? Math.round((ekNetto * (1 + Number(ekTaxRate) / 100) + Number.EPSILON) * 100) / 100
: null;
const hasPriceInfo =
vkNetto != null || vkBrutto != null || ekNetto != null || ekBrutto != null || kind === 'quote' || kind === 'order';
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
py: 0.25,
flexWrap: 'wrap',
}}
>
{/* Article Photo Thumbnail */}
<Box
sx={{
width: 28,
height: 28,
minWidth: 28,
borderRadius: 0.75,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={28}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
)}
</Box>
{/* Internal Article SKU */}
{cachedArticle.sku && (
<Chip
component={RouterLink}
to={artikelDetailPath(cachedArticle)}
clickable
size="small"
label={`Art.-Nr.: ${cachedArticle.sku}`}
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: 'primary.50',
color: 'primary.dark',
border: '1px solid',
borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}}
/>
)}
{/* Brand */}
{cachedArticle.brand?.name && (
<BrandLabel
brand={cachedArticle.brand}
size={16}
typographyProps={{
variant: 'caption',
color: 'text.secondary',
fontWeight: 500,
}}
/>
)}
{/* Article Name: omit if already editable in line 1 or if kind is order */}
{!hasNameColumn && !isOrder && cachedArticle.name && (
<Typography
variant="caption"
fontWeight={600}
noWrap
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
title={cachedArticle.name}
>
{cachedArticle.name}
</Typography>
)}
{/* Primary EAN: omit for order */}
{!isOrder && primaryEan && (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontFamily: 'monospace',
fontSize: '0.68rem',
bgcolor: 'action.hover',
px: 0.5,
py: 0.1,
borderRadius: 0.5,
}}
>
EAN: {primaryEan}
</Typography>
)}
{/* EK Netto, EK Brutto, VK Netto, VK Brutto */}
{hasPriceInfo && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
ml: 'auto',
whiteSpace: 'nowrap',
}}
>
{/* EK Netto */}
<Tooltip
title={
topSupplier?.supplierName
? `Lieferant: ${topSupplier.supplierName}${topSupplier.sku ? ` (Lief.-Art.-Nr.: ${topSupplier.sku})` : ''} (Sort: ${topSupplier.sort ?? 0})`
: 'Kein Lieferant mit EK-Preis hinterlegt'
}
>
<Typography
variant="caption"
fontWeight={600}
sx={{
color: ekNetto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Netto: {ekNetto != null ? formatPrice(ekNetto) : '—'}
</Typography>
</Tooltip>
{/* EK Brutto */}
<Tooltip
title={
topSupplier?.supplierName
? `Lieferant: ${topSupplier.supplierName} (inkl. ${ekTaxRate}% MwSt.)`
: 'Kein Lieferant mit EK-Preis hinterlegt'
}
>
<Typography
variant="caption"
fontWeight={600}
sx={{
color: ekBrutto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Brutto: {ekBrutto != null ? formatPrice(ekBrutto) : '—'}
</Typography>
</Tooltip>
{/* VK Netto */}
<Typography
variant="caption"
fontWeight={600}
sx={{
color: vkNetto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
VK Netto: {vkNetto != null ? formatPrice(vkNetto) : '—'}
</Typography>
{/* VK Brutto */}
<Typography
variant="caption"
fontWeight={600}
sx={{
color: vkBrutto != null ? 'primary.dark' : 'text.disabled',
fontSize: '0.72rem',
}}
>
VK Brutto: {vkBrutto != null ? formatPrice(vkBrutto) : '—'}
</Typography>
</Box>
)}
</Box>
);
}
if (rawSku) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
)}
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
Kein Artikel für SKU &bdquo;{rawSku}&ldquo; gefunden.
</Typography>
</Box>
);
}
if (this.props.onSearchArticle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
</Box>
);
}
return null;
}
renderDeliverySecondLine(rawSku) {
const { supplierArticle, loading } = this.props;
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.25 }}>
<CircularProgress size={14} thickness={5} sx={{ color: 'primary.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.72rem' }}>
Artikeldaten werden geladen…
</Typography>
</Box>
);
}
if (supplierArticle) {
const article = supplierArticle.article || {};
const fullArticle = this.state.cachedArticle || article;
const photo = fullArticle.photos?.[0] || fullArticle.articlepic?.[0] || article.photos?.[0] || article.articlepic?.[0];
const fullHash = photo?.hash || photo?.fullHash;
const thumbHash = photo?.thumbhash || photo?.thumbHash;
const thumbW = photo?.thumbw || photo?.thumbW;
const thumbH = photo?.thumbh || photo?.thumbH;
const hasPhoto = fullHash != null || thumbHash != null;
const eans = fullArticle.eans ?? fullArticle.articleean ?? article.eans ?? article.articleean ?? [];
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
const priceNet = supplierArticle.price?.net;
const taxRate = supplierArticle.price?.taxRate;
const vkPrice = primaryPrice(fullArticle);
const vkNetto = vkPrice?.net;
const vkBrutto = vkPrice
? priceBrutto(vkPrice) ??
(vkPrice.net != null && vkPrice.taxrate != null
? vkPrice.net * (1 + vkPrice.taxrate / 100)
: null)
: null;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
py: 0.25,
flexWrap: 'wrap',
}}
>
{/* Unlink button at the front */}
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
<Tooltip title="Lieferanten-Verknüpfung aufheben">
<span>
<IconButton
size="small"
color="error"
aria-label="Lieferanten-Verknüpfung entfernen"
disabled={this.props.saving || this.props.supplierArticleBusy}
onClick={this.handleRemoveSupplierArticle}
sx={{ p: 0.25, mr: -0.25 }}
>
<LinkOffOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
)}
{/* Article Photo Thumbnail */}
<Box
sx={{
width: 28,
height: 28,
minWidth: 28,
borderRadius: 0.75,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={28}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
)}
</Box>
{/* Internal Article SKU */}
{article.sku && (
<Chip
component={RouterLink}
to={artikelDetailPath(article)}
clickable
size="small"
label={`Art.-Nr.: ${article.sku}`}
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: 'primary.50',
color: 'primary.dark',
border: '1px solid',
borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}}
/>
)}
{/* Brand */}
{(fullArticle.brand?.name || article.brand?.name) && (
<BrandLabel
brand={fullArticle.brand || article.brand}
size={16}
typographyProps={{
variant: 'caption',
color: 'text.secondary',
fontWeight: 500,
}}
/>
)}
{/* Article Name */}
{(fullArticle.name || article.name) && (
<Typography
variant="caption"
fontWeight={600}
noWrap
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
title={fullArticle.name || article.name}
>
{fullArticle.name || article.name}
</Typography>
)}
{/* Supplier SKU if distinct */}
{supplierArticle.sku && supplierArticle.sku !== rawSku && supplierArticle.sku !== article.sku && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
(Lief.-SKU: <strong>{supplierArticle.sku}</strong>)
</Typography>
)}
{/* Primary EAN */}
{primaryEan && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', fontSize: '0.68rem', bgcolor: 'action.hover', px: 0.5, py: 0.1, borderRadius: 0.5 }}
>
EAN: {primaryEan}
</Typography>
)}
{/* Stored Purchase Price (Netto), VK Netto & VK Brutto */}
{(priceNet != null || vkNetto != null || vkBrutto != null) && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
ml: 'auto',
whiteSpace: 'nowrap',
}}
>
{priceNet != null && (
<Typography
variant="caption"
fontWeight={600}
sx={{ color: 'success.dark', fontSize: '0.72rem' }}
>
EK Netto: {formatPrice(priceNet)}
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
</Typography>
)}
{vkNetto != null && (
<Typography
variant="caption"
fontWeight={600}
sx={{ color: 'text.secondary', fontSize: '0.72rem' }}
>
VK Netto: {formatPrice(vkNetto)}
</Typography>
)}
{vkBrutto != null && (
<Typography
variant="caption"
fontWeight={600}
sx={{ color: 'primary.dark', fontSize: '0.72rem' }}
>
VK Brutto: {formatPrice(vkBrutto)}
</Typography>
)}
</Box>
)}
{this.renderLedgerChip()}
</Box>
);
}
if (rawSku) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
{this.props.onCreateArticle && (
<Button
size="small"
variant="text"
startIcon={<AddIcon sx={{ fontSize: 14 }} />}
onClick={this.handleCreateArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel anlegen
</Button>
)}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
)}
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
{this.props.kind === 'delivery'
? `Kein Lieferanten-Artikel für SKU „${rawSku}“ gefunden.`
: `Kein Artikel für SKU „${rawSku}“ gefunden.`}
</Typography>
{this.renderLedgerChip()}
</Box>
);
}
if (this.props.ledgerLine) {
const chip = this.renderLedgerChip();
if (!chip) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
{chip}
</Box>
);
}
return null;
}
render() {
const { columns = LINE_COLUMNS, saving, ledgerLine } = this.props;
const secondLine = this.renderSecondLine();
return (
<Fragment>
{/* Row 1: Editable Line Inputs */}
<TableRow hover sx={{ '& > td': { borderBottom: 'none', p: 0 } }}>
{columns.map((col, colIdx) => (
<TableCell
key={col.id}
align={col.align}
sx={{
p: 0,
width: col.width,
minWidth: col.width,
height: 40,
verticalAlign: 'top',
position: 'relative',
overflow: 'visible',
}}
>
{this.renderField(col, colIdx, columns.length)}
</TableCell>
))}
<TableCell
padding="none"
sx={{
py: 0.25,
pl: 0.5,
pr: 0.5,
verticalAlign: 'middle',
width: (this.props.onMove ? 80 : 36) + (this.props.kind === 'delivery' && Boolean(this.props.onBookLine) ? 36 : 0),
whiteSpace: 'nowrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{this.props.kind === 'delivery' && Boolean(this.props.onBookLine) && (
<span>
<IconButton
size="small"
aria-label="Position verbuchen"
disabled={saving || !ledgerLine}
onClick={(e) => {
e.stopPropagation();
if (ledgerLine) {
this.props.onBookLine(ledgerLine);
}
}}
color={
!ledgerLine
? 'default'
: ledgerLine.isBooked
? 'success'
: ledgerLine.bookedAmount > 0
? 'warning'
: 'primary'
}
sx={{ p: 0.35 }}
>
{ledgerLine?.isBooked ? (
<CheckCircleOutlineOutlinedIcon sx={{ fontSize: 18 }} />
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 18 }} />
)}
</IconButton>
</span>
)}
{this.props.onMove && (
<>
<span>
<IconButton
size="small"
aria-label="Nach oben verschieben"
disabled={saving || this.props.isFirstLine}
onClick={this.handleMoveUp}
sx={{ p: 0.35 }}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
<span>
<IconButton
size="small"
aria-label="Nach unten verschieben"
disabled={saving || this.props.isLastLine}
onClick={this.handleMoveDown}
sx={{ p: 0.35 }}
>
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</>
)}
<IconButton
size="small"
aria-label="Zeile entfernen"
disabled={saving || Boolean(this.props.isOnShipment)}
onClick={this.handleRemove}
title={this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht entfernt werden' : 'Zeile entfernen'}
sx={{ p: 0.35 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</TableCell>
</TableRow>
{/* Row 2: Matched Supplier Article Details */}
{secondLine && (
<TableRow sx={{ '& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}>
<TableCell
colSpan={columns.length + 1}
sx={{
py: 0.4,
px: 1,
bgcolor: 'transparent',
}}
>
{secondLine}
</TableCell>
</TableRow>
)}
</Fragment>
);
}
}
export default ManifestFormLine;