912 lines
29 KiB
JavaScript
912 lines
29 KiB
JavaScript
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 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 } 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 {
|
||
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,
|
||
};
|
||
this.handleRemove = this.handleRemove.bind(this);
|
||
this.handleBlur = this.handleBlur.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?.();
|
||
}
|
||
|
||
subscribeArticle(sku) {
|
||
this.unsubArticle?.();
|
||
this.unsubArticle = null;
|
||
if (!sku) {
|
||
if (this.state.cachedArticle || this.state.loadingArticle) {
|
||
this.setState({ cachedArticle: null, loadingArticle: false });
|
||
}
|
||
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 } : {}),
|
||
};
|
||
});
|
||
}
|
||
|
||
handleRemove() {
|
||
this.props.onRemove(this.props.line.key);
|
||
}
|
||
|
||
handleBlur(e, columnId) {
|
||
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);
|
||
|
||
return (
|
||
<Tooltip
|
||
title={
|
||
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)`
|
||
}
|
||
>
|
||
<Chip
|
||
size="small"
|
||
clickable={isClickable}
|
||
onClick={
|
||
isClickable
|
||
? (e) => {
|
||
e.stopPropagation();
|
||
onBookLine(ledgerLine);
|
||
}
|
||
: undefined
|
||
}
|
||
icon={
|
||
isBooked ? (
|
||
<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||
) : (
|
||
<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||
)
|
||
}
|
||
label={
|
||
isBooked
|
||
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
|
||
: isPartial
|
||
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
|
||
: 'Lager: offen'
|
||
}
|
||
color={isBooked ? 'success' : isPartial ? 'warning' : 'default'}
|
||
variant="outlined"
|
||
sx={{
|
||
height: 20,
|
||
fontSize: '0.68rem',
|
||
fontWeight: 600,
|
||
cursor: isClickable ? 'pointer' : 'default',
|
||
bgcolor: isBooked ? 'success.50' : undefined,
|
||
'&:hover': isClickable
|
||
? {
|
||
bgcolor: isBooked
|
||
? 'success.100'
|
||
: isPartial
|
||
? 'warning.100'
|
||
: 'action.hover',
|
||
}
|
||
: undefined,
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
renderField(column, colIdx, totalCols) {
|
||
const { id, align, type } = column;
|
||
const { line, saving, onChange, columns } = this.props;
|
||
|
||
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: '100%',
|
||
minHeight: 38,
|
||
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;
|
||
if (netNum != null) {
|
||
val = String(Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 { dirtyFields } = this.props;
|
||
const isFieldDirty = Boolean(
|
||
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
|
||
);
|
||
|
||
return (
|
||
<TextField
|
||
size="small"
|
||
fullWidth
|
||
type={type}
|
||
value={val ?? ''}
|
||
error={isError}
|
||
disabled={saving}
|
||
autoComplete="off"
|
||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||
onBlur={(e) => this.handleBlur(e, id)}
|
||
slotProps={{
|
||
htmlInput: {
|
||
inputMode: type === 'number' ? 'decimal' : 'text',
|
||
style: { textAlign: align },
|
||
autoComplete: 'off',
|
||
step: type === 'number' ? 'any' : undefined,
|
||
},
|
||
}}
|
||
sx={{
|
||
'& .MuiOutlinedInput-root': {
|
||
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
|
||
ml: isFirstInput ? 0 : '-1px',
|
||
...(isFieldDirty && {
|
||
bgcolor: 'rgba(237, 108, 2, 0.08)',
|
||
}),
|
||
'&:hover': {
|
||
zIndex: 1,
|
||
},
|
||
'&.Mui-focused': {
|
||
zIndex: 2,
|
||
},
|
||
},
|
||
...(isFieldDirty && {
|
||
zIndex: 1,
|
||
'& .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'warning.main',
|
||
borderWidth: 2,
|
||
},
|
||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'warning.main',
|
||
},
|
||
}),
|
||
'& input[type=number]': {
|
||
MozAppearance: 'textfield',
|
||
},
|
||
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
|
||
{
|
||
WebkitAppearance: 'none',
|
||
margin: 0,
|
||
},
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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;
|
||
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',
|
||
}}
|
||
>
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* VK Netto & VK Brutto: omit end on 2nd line for order */}
|
||
{!isOrder && (vkNetto != null || vkBrutto != null) && (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1.5,
|
||
ml: 'auto',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{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>
|
||
)}
|
||
</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 „{rawSku}“ 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) {
|
||
return (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||
{this.renderLedgerChip()}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
render() {
|
||
const { columns = LINE_COLUMNS, saving } = 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,
|
||
verticalAlign: 'middle',
|
||
}}
|
||
>
|
||
{this.renderField(col, colIdx, columns.length)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell padding="none" sx={{ py: 0.25, pl: 0.75, pr: 0, verticalAlign: 'middle', width: 40 }}>
|
||
<IconButton
|
||
size="small"
|
||
aria-label="Zeile entfernen"
|
||
disabled={saving}
|
||
onClick={this.handleRemove}
|
||
>
|
||
<DeleteOutlinedIcon fontSize="small" />
|
||
</IconButton>
|
||
</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;
|