feat(article): implement articlesupplier editor tab

- ApiClient: add getSuppliers, addSupplier, updateSupplier,
  orderSuppliers, deleteSupplier on article namespace
- api/index.js: expose same methods in legacy http client
- StorageManager: add #supplierWatchers Map and watchArticleSuppliers()
  backed by article-<sku> SSE signal; wired up as article.supplierWatch
- ArticleTileTab: pass article prop to ArticleSupplierTab
- ArticleSupplierDialog (new): dialog to assign / edit a supplier link
  (supplier autocomplete, Lief.-SKU, VPE, stock, EK Netto + MwSt.)
- ArticleSupplierTab: full supplier management table with reordering
  (up/down arrows → PUT /supplier/order), inline edit and delete,
  stock badge, purchase price display and empty state
This commit is contained in:
seb
2026-09-17 23:37:41 +02:00
parent 6e72ae84bd
commit 795043fe99
6 changed files with 833 additions and 10 deletions

View File

@@ -0,0 +1,232 @@
import { PureComponent } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import InputAdornment from '@mui/material/InputAdornment';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { companyTaxRateOptions } from '../../utils/articleDetail';
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
function parseNumber(value, min = 0) {
if (value === '' || value == null) return null;
const num = Number(String(value).trim().replace(',', '.'));
return Number.isFinite(num) && num >= min ? num : null;
}
export default class ArticleSupplierDialog extends PureComponent {
constructor(props) {
super(props);
this.state = this.initState(props);
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState(this.initState(this.props));
}
}
initState(props) {
const item = props.item;
const defaultTaxRate =
props.company?.taxrate != null ? Number(props.company.taxrate) : 19;
return {
entityId: item?.entityId ? Number(item.entityId) : null,
sku: item?.sku ?? '',
pu: item?.pu != null ? String(item.pu) : '1',
stock: item?.stock != null ? String(item.stock) : '',
netPrice:
item?.price?.net != null ? formatPriceAmount(item.price.net) : '',
taxRate:
item?.price?.taxRate != null
? Number(item.price.taxRate)
: defaultTaxRate,
submitting: false,
};
}
handleSubmit = async (e) => {
e.preventDefault();
const { entityId, sku, pu, stock, netPrice, taxRate } = this.state;
const isEdit = Boolean(this.props.item?.id);
if (!isEdit && !entityId) return;
const parsedPu = parseNumber(pu, 0.0001);
if (parsedPu == null || parsedPu <= 0) {
return;
}
const parsedStock = parseNumber(stock, 0);
const parsedNet = netPrice !== '' ? parsePriceInput(netPrice) : null;
this.setState({ submitting: true });
try {
await this.props.onSave({
id: this.props.item?.id,
entityId: isEdit ? this.props.item.entityId : entityId,
sku: sku.trim() || null,
pu: parsedPu,
stock: parsedStock,
netPrice: parsedNet,
taxRate: parsedNet != null ? taxRate : undefined,
});
} finally {
this.setState({ submitting: false });
}
};
render() {
const {
open,
onClose,
suppliers = [],
item,
company,
assignedEntityIds = [],
} = this.props;
const { entityId, sku, pu, stock, netPrice, taxRate, submitting } =
this.state;
const isEdit = Boolean(item?.id);
const availableSuppliers = isEdit
? suppliers
: suppliers.filter(
(s) => !assignedEntityIds.includes(s.id) || s.id === entityId,
);
const selectedSupplier =
suppliers.find((s) => s.id === entityId) ?? null;
const taxOptions = companyTaxRateOptions(company, taxRate);
const parsedPu = parseNumber(pu, 0.0001);
const puValid = parsedPu != null && parsedPu > 0;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<form onSubmit={this.handleSubmit}>
<DialogTitle sx={{ fontWeight: 600 }}>
{isEdit ? 'Lieferant bearbeiten' : 'Lieferant zuweisen'}
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2.5}>
{isEdit ? (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferant
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{item.supplierName ?? `Lieferant #${item.entityId}`}
</Typography>
</Box>
) : (
<Autocomplete
options={availableSuppliers}
getOptionLabel={(opt) => opt.name ?? `Lieferant #${opt.id}`}
value={selectedSupplier}
onChange={(_e, val) => this.setState({ entityId: val ? val.id : null })}
renderInput={(params) => (
<TextField
{...params}
label="Lieferant auswählen"
required
placeholder="Lieferant suchen…"
/>
)}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
/>
)}
<TextField
label="Lieferanten-Artikelnummer (SKU)"
value={sku}
onChange={(e) => this.setState({ sku: e.target.value })}
placeholder="z. B. LF-09412"
helperText="Wird beim Abgleich von Lieferscheinen / Wareneingängen genutzt"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Verpackungseinheit (VPE / PU)"
type="number"
required
error={!puValid}
helperText={!puValid ? 'Muss größer als 0 sein' : undefined}
inputProps={{ step: 'any', min: 0.0001 }}
value={pu}
onChange={(e) => this.setState({ pu: e.target.value })}
sx={{ flex: 1 }}
/>
<TextField
label="Bestand beim Lieferanten"
type="number"
inputProps={{ step: 'any', min: 0 }}
value={stock}
onChange={(e) => this.setState({ stock: e.target.value })}
sx={{ flex: 1 }}
helperText="Optional (in Stk / Mengeneinheit)"
/>
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Einkaufspreis Netto"
value={netPrice}
onChange={(e) => this.setState({ netPrice: e.target.value })}
placeholder="0,00"
InputProps={{
endAdornment: (
<InputAdornment position="end">€</InputAdornment>
),
}}
sx={{ flex: 1 }}
helperText="Optionaler Einkaufspreis (EK)"
/>
<TextField
select
label="MwSt.-Satz"
value={taxRate}
onChange={(e) =>
this.setState({ taxRate: Number(e.target.value) })
}
sx={{ width: { sm: 160 } }}
disabled={!netPrice}
>
{taxOptions.map((rate) => (
<MenuItem key={rate} value={rate}>
{rate} %
</MenuItem>
))}
</TextField>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit" disabled={submitting}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={submitting || (!isEdit && !entityId) || !puValid}
>
{isEdit ? 'Aktualisieren' : 'Hinzufügen'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

View File

@@ -155,7 +155,7 @@ class ArticleTileTab extends PureComponent {
case ARTICLE_TAB.history:
return <ArticleHistoryTab sku={articleData.sku} />;
case ARTICLE_TAB.supplier:
return <ArticleSupplierTab />;
return <ArticleSupplierTab article={articleData} />;
case ARTICLE_TAB.seo:
return <ArticleSeoTab article={articleData} />;
case ARTICLE_TAB.label:

View File

@@ -1,12 +1,448 @@
import { Component } from 'react';
import { PureComponent } from 'react';
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 Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { notifyError } from '../../../utils/notify';
import { formatPrice } from '../../../utils/price';
import ArticleSupplierDialog from '../ArticleSupplierDialog';
export default class ArticleSupplierTab extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
loading: true,
assignedSuppliers: [],
companySuppliers: [],
company: null,
dialogOpen: false,
editItem: null,
busyId: null,
};
this.unwatchSuppliers = null;
}
componentDidMount() {
this.initData();
}
componentDidUpdate(prevProps) {
if (prevProps.article?.sku !== this.props.article?.sku) {
this.initData();
}
}
componentWillUnmount() {
this.unwatchSuppliers?.();
this.unwatchSuppliers = null;
}
async initData() {
const sku = this.props.article?.sku;
if (!sku) return;
this.unwatchSuppliers?.();
this.setState({ loading: true });
// 1. Subscribe to SSE-backed supplier updates
const { unwatch, refresh } = this.context.article.supplierWatch(
sku,
(assignedSuppliers) => {
this.setState({
assignedSuppliers: assignedSuppliers ?? [],
loading: false,
});
},
);
this.unwatchSuppliers = unwatch;
this.refreshSuppliers = refresh;
// 2. Fetch available company suppliers and company metadata
try {
const [companySuppliers, company] = await Promise.all([
this.context.suppliers ?? this.context.supplierPromise,
this.context.company ?? this.context.companyPromise,
]);
this.setState({
companySuppliers: companySuppliers ?? [],
company: company ?? null,
});
} catch (err) {
notifyError(err);
this.setState({ loading: false });
}
}
handleOpenAdd = () => {
this.setState({ dialogOpen: true, editItem: null });
};
handleOpenEdit = (item) => {
this.setState({ dialogOpen: true, editItem: item });
};
handleCloseDialog = () => {
this.setState({ dialogOpen: false, editItem: null });
};
handleSave = async (payload) => {
const sku = this.props.article?.sku;
if (!sku) return;
try {
if (payload.id) {
await this.context.article.updateSupplier(sku, payload.id, {
sku: payload.sku,
pu: payload.pu,
stock: payload.stock,
netPrice: payload.netPrice,
taxRate: payload.taxRate,
});
enqueueSnackbar('Lieferantendaten aktualisiert', { variant: 'success' });
} else {
await this.context.article.addSupplier(sku, {
entityId: payload.entityId,
sku: payload.sku,
pu: payload.pu,
stock: payload.stock,
netPrice: payload.netPrice,
taxRate: payload.taxRate,
});
enqueueSnackbar('Lieferant erfolgreich zugewiesen', { variant: 'success' });
}
this.handleCloseDialog();
await this.refreshSuppliers?.();
} catch (err) {
notifyError(err);
throw err;
}
};
handleDelete = async (item) => {
const sku = this.props.article?.sku;
if (!sku) return;
const supplierName = item.supplierName ?? `Lieferant #${item.entityId}`;
if (!window.confirm(`Lieferantenzuweisung zu „${supplierName}“ wirklich löschen?`)) {
return;
}
this.setState({ busyId: item.id });
try {
await this.context.article.deleteSupplier(sku, item.id);
enqueueSnackbar('Lieferant entfernt', { variant: 'success' });
await this.refreshSuppliers?.();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyId: null });
}
};
handleMove = async (index, direction) => {
const sku = this.props.article?.sku;
const { assignedSuppliers } = this.state;
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= assignedSuppliers.length || !sku) {
return;
}
const nextList = [...assignedSuppliers];
const [moved] = nextList.splice(index, 1);
nextList.splice(targetIndex, 0, moved);
const ids = nextList.map((row) => row.id);
this.setState({ assignedSuppliers: nextList, busyId: moved.id });
try {
await this.context.article.orderSuppliers(sku, { ids });
await this.refreshSuppliers?.();
} catch (err) {
notifyError(err);
await this.refreshSuppliers?.();
} finally {
this.setState({ busyId: null });
}
};
export default class ArticleSupplierTab extends Component {
render() {
const {
loading,
assignedSuppliers,
companySuppliers,
company,
dialogOpen,
editItem,
busyId,
} = this.state;
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<CircularProgress size={32} />
</Box>
);
}
const assignedEntityIds = assignedSuppliers.map((s) => s.entityId);
return (
<Typography variant="body2" color="text.secondary">
Supplier
</Typography>
<Box sx={{ width: '100%' }}>
{/* Header Bar */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Zugeordnete Lieferanten ({assignedSuppliers.length})
</Typography>
<Typography variant="caption" color="text.secondary">
Bezugsquellen, Lieferanten-Artikelnr. und Konditionen für diesen Artikel
</Typography>
</Box>
<Button
size="small"
variant="contained"
startIcon={<AddIcon />}
onClick={this.handleOpenAdd}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Lieferant zuweisen
</Button>
</Box>
{/* Content Table or Empty State */}
{assignedSuppliers.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 6,
px: 2,
borderRadius: 2,
border: '1px dashed rgba(0, 0, 0, 0.15)',
bgcolor: 'rgba(0, 0, 0, 0.01)',
textAlign: 'center',
}}
>
<LocalShippingOutlinedIcon sx={{ fontSize: 48, mb: 1.5, color: 'text.secondary', opacity: 0.6 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Noch keine Lieferanten zugewiesen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 380, mb: 2 }}>
Weise diesem Artikel Lieferanten zu, um Einkaufspreise, VPE und Artikelnummern für Wareneingänge zu hinterlegen.
</Typography>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={this.handleOpenAdd}
size="small"
sx={{ textTransform: 'none' }}
>
Jetzt Lieferant zuweisen
</Button>
</Box>
) : (
<Table
size="small"
sx={{
'& td': { verticalAlign: 'middle' },
'& th': { fontWeight: 600, color: 'text.secondary', fontSize: '0.8rem' },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: 60, pl: 1 }}>Reihe</TableCell>
<TableCell>Lieferant</TableCell>
<TableCell>Lief.-Art.-Nr.</TableCell>
<TableCell align="right">VPE (PU)</TableCell>
<TableCell align="right">Lagerbestand</TableCell>
<TableCell align="right">Einkaufspreis</TableCell>
<TableCell align="right" sx={{ width: 100, pr: 1 }}>
Aktionen
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{assignedSuppliers.map((row, index) => {
const isBusy = busyId === row.id;
const netPrice = row.price?.net;
const taxRate = row.price?.taxRate;
return (
<TableRow
key={row.id}
hover
sx={{
opacity: isBusy ? 0.5 : 1,
transition: 'opacity 0.2s',
}}
>
{/* Sort Order & Reorder Controls */}
<TableCell sx={{ pl: 1 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography variant="caption" sx={{ minWidth: 16, fontWeight: 700, color: 'text.secondary' }}>
{index + 1}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Tooltip title="Nach oben">
<span>
<IconButton
size="small"
disabled={index === 0 || isBusy}
onClick={() => this.handleMove(index, -1)}
sx={{ p: 0.2 }}
>
<ArrowUpwardIcon sx={{ fontSize: 13 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Nach unten">
<span>
<IconButton
size="small"
disabled={index === assignedSuppliers.length - 1 || isBusy}
onClick={() => this.handleMove(index, 1)}
sx={{ p: 0.2 }}
>
<ArrowDownwardIcon sx={{ fontSize: 13 }} />
</IconButton>
</span>
</Tooltip>
</Box>
</Stack>
</TableCell>
{/* Supplier Name */}
<TableCell sx={{ fontWeight: 600 }}>
{row.supplierName ?? `Lieferant #${row.entityId}`}
</TableCell>
{/* Supplier SKU */}
<TableCell>
{row.sku ? (
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 500 }}>
{row.sku}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
—
</Typography>
)}
</TableCell>
{/* PU / VPE */}
<TableCell align="right">
<Typography variant="body2">{row.pu ?? 1}</Typography>
</TableCell>
{/* Stock */}
<TableCell align="right">
{row.stock != null ? (
<Chip
size="small"
label={`${row.stock} Stk`}
color={row.stock > 0 ? 'success' : 'default'}
variant="outlined"
sx={{ fontWeight: 500, height: 22, fontSize: '0.75rem' }}
/>
) : (
<Typography variant="body2" color="text.secondary">
—
</Typography>
)}
</TableCell>
{/* Purchase Price Net & Tax */}
<TableCell align="right">
{netPrice != null ? (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatPrice(netPrice)}
</Typography>
{taxRate != null ? (
<Typography variant="caption" color="text.secondary">
zzgl. {taxRate} % MwSt.
</Typography>
) : null}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
—
</Typography>
)}
</TableCell>
{/* Actions */}
<TableCell align="right" sx={{ pr: 1 }}>
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Bearbeiten">
<IconButton
size="small"
onClick={() => this.handleOpenEdit(row)}
disabled={isBusy}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Entfernen">
<IconButton
size="small"
color="error"
onClick={() => this.handleDelete(row)}
disabled={isBusy}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{/* Add / Edit Dialog */}
<ArticleSupplierDialog
open={dialogOpen}
onClose={this.handleCloseDialog}
onSave={this.handleSave}
suppliers={companySuppliers}
assignedEntityIds={assignedEntityIds}
company={company}
item={editItem}
/>
</Box>
);
}
}