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:
232
src/components/article/ArticleSupplierDialog.jsx
Normal file
232
src/components/article/ArticleSupplierDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user