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>
);
}
}