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:
@@ -316,6 +316,20 @@ export class ApiClient {
|
||||
/** PUT /api/article/{sku}/categories */
|
||||
setCategories: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
||||
/** GET /api/article/{sku}/supplier */
|
||||
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
||||
/** POST /api/article/{sku}/supplier */
|
||||
addSupplier: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
|
||||
/** PATCH /api/article/{sku}/supplier/{id} */
|
||||
updateSupplier: (sku, id, body) =>
|
||||
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
|
||||
/** PUT /api/article/{sku}/supplier/order */
|
||||
orderSuppliers: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
|
||||
/** DELETE /api/article/{sku}/supplier/{id} */
|
||||
deleteSupplier: (sku, id) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.posterminal = {
|
||||
|
||||
@@ -80,11 +80,15 @@ export class StorageManager extends ApiClient {
|
||||
#articleWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
||||
#historyWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
||||
#supplierWatchers = new Map();
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
super(baseUrl);
|
||||
this.#initCaches();
|
||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
||||
this.article.supplierWatch = (sku, onSuppliers) =>
|
||||
this.watchArticleSuppliers(sku, onSuppliers);
|
||||
}
|
||||
|
||||
/** Settled article detail for sku, or undefined. */
|
||||
@@ -124,7 +128,10 @@ export class StorageManager extends ApiClient {
|
||||
*/
|
||||
watchArticle(sku, onArticle) {
|
||||
const key = String(sku);
|
||||
const needListen = !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key);
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#articleWatchers.get(key);
|
||||
if (!watchers) {
|
||||
@@ -144,7 +151,10 @@ export class StorageManager extends ApiClient {
|
||||
set.delete(onArticle);
|
||||
if (set.size === 0) {
|
||||
this.#articleWatchers.delete(key);
|
||||
if (!this.#historyWatchers.has(key)) {
|
||||
if (
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +224,10 @@ export class StorageManager extends ApiClient {
|
||||
notifyError(err);
|
||||
});
|
||||
|
||||
const needListen = !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key);
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#historyWatchers.get(key);
|
||||
if (!watchers) {
|
||||
@@ -236,7 +249,10 @@ export class StorageManager extends ApiClient {
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#historyWatchers.delete(key);
|
||||
if (!this.#articleWatchers.has(key)) {
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
@@ -249,6 +265,68 @@ export class StorageManager extends ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppliers for an article and refetch on SSE `article-<sku>`.
|
||||
* @param {string} sku
|
||||
* @param {(suppliers: Array<object>) => void} onSuppliers
|
||||
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
|
||||
*/
|
||||
watchArticleSuppliers(sku, onSuppliers) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await this.article.getSuppliers(key);
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/supplier');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
onSuppliers(list);
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
};
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#supplierWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#supplierWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#supplierWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#supplierWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#onArticleSignal = (event) => {
|
||||
const sku = event.type.slice('article-'.length);
|
||||
if (this.#articleWatchers.has(sku)) {
|
||||
@@ -258,6 +336,10 @@ export class StorageManager extends ApiClient {
|
||||
if (historyWatchers) {
|
||||
for (const run of historyWatchers) run();
|
||||
}
|
||||
const supplierWatchers = this.#supplierWatchers.get(sku);
|
||||
if (supplierWatchers) {
|
||||
for (const run of supplierWatchers) run();
|
||||
}
|
||||
};
|
||||
|
||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
||||
@@ -393,6 +475,7 @@ export class StorageManager extends ApiClient {
|
||||
for (const sku of new Set([
|
||||
...this.#articleWatchers.keys(),
|
||||
...this.#historyWatchers.keys(),
|
||||
...this.#supplierWatchers.keys(),
|
||||
])) {
|
||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
@@ -329,6 +329,64 @@ export const article = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] }
|
||||
*/
|
||||
getSuppliers(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/supplier
|
||||
* Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
|
||||
* → 201 { supplier: ArticleSupplier }
|
||||
*/
|
||||
addSupplier(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku/supplier/:id
|
||||
* Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
|
||||
* → 200 { supplier: ArticleSupplier }
|
||||
*/
|
||||
updateSupplier(sku, id, body) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/supplier/order
|
||||
* Body: { ids: number[] }
|
||||
* → 200 { ids: number[] }
|
||||
*/
|
||||
orderSuppliers(sku, ids) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, {
|
||||
method: 'PUT',
|
||||
body: { ids },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/supplier/:id → 204
|
||||
*/
|
||||
deleteSupplier(sku, id) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/export.zip → application/zip (binary download)
|
||||
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user