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 */
|
/** PUT /api/article/{sku}/categories */
|
||||||
setCategories: (sku, body) =>
|
setCategories: (sku, body) =>
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { 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 = {
|
this.posterminal = {
|
||||||
|
|||||||
@@ -80,11 +80,15 @@ export class StorageManager extends ApiClient {
|
|||||||
#articleWatchers = new Map();
|
#articleWatchers = new Map();
|
||||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
||||||
#historyWatchers = new Map();
|
#historyWatchers = new Map();
|
||||||
|
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
||||||
|
#supplierWatchers = new Map();
|
||||||
|
|
||||||
constructor(baseUrl = window.location.origin) {
|
constructor(baseUrl = window.location.origin) {
|
||||||
super(baseUrl);
|
super(baseUrl);
|
||||||
this.#initCaches();
|
this.#initCaches();
|
||||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
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. */
|
/** Settled article detail for sku, or undefined. */
|
||||||
@@ -124,7 +128,10 @@ export class StorageManager extends ApiClient {
|
|||||||
*/
|
*/
|
||||||
watchArticle(sku, onArticle) {
|
watchArticle(sku, onArticle) {
|
||||||
const key = String(sku);
|
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);
|
let watchers = this.#articleWatchers.get(key);
|
||||||
if (!watchers) {
|
if (!watchers) {
|
||||||
@@ -144,7 +151,10 @@ export class StorageManager extends ApiClient {
|
|||||||
set.delete(onArticle);
|
set.delete(onArticle);
|
||||||
if (set.size === 0) {
|
if (set.size === 0) {
|
||||||
this.#articleWatchers.delete(key);
|
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);
|
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +224,10 @@ export class StorageManager extends ApiClient {
|
|||||||
notifyError(err);
|
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);
|
let watchers = this.#historyWatchers.get(key);
|
||||||
if (!watchers) {
|
if (!watchers) {
|
||||||
@@ -236,7 +249,10 @@ export class StorageManager extends ApiClient {
|
|||||||
set.delete(run);
|
set.delete(run);
|
||||||
if (set.size === 0) {
|
if (set.size === 0) {
|
||||||
this.#historyWatchers.delete(key);
|
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);
|
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) => {
|
#onArticleSignal = (event) => {
|
||||||
const sku = event.type.slice('article-'.length);
|
const sku = event.type.slice('article-'.length);
|
||||||
if (this.#articleWatchers.has(sku)) {
|
if (this.#articleWatchers.has(sku)) {
|
||||||
@@ -258,6 +336,10 @@ export class StorageManager extends ApiClient {
|
|||||||
if (historyWatchers) {
|
if (historyWatchers) {
|
||||||
for (const run of historyWatchers) run();
|
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. */
|
/** 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([
|
for (const sku of new Set([
|
||||||
...this.#articleWatchers.keys(),
|
...this.#articleWatchers.keys(),
|
||||||
...this.#historyWatchers.keys(),
|
...this.#historyWatchers.keys(),
|
||||||
|
...this.#supplierWatchers.keys(),
|
||||||
])) {
|
])) {
|
||||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
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)
|
* GET /api/article/export.zip → application/zip (binary download)
|
||||||
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
|
* `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:
|
case ARTICLE_TAB.history:
|
||||||
return <ArticleHistoryTab sku={articleData.sku} />;
|
return <ArticleHistoryTab sku={articleData.sku} />;
|
||||||
case ARTICLE_TAB.supplier:
|
case ARTICLE_TAB.supplier:
|
||||||
return <ArticleSupplierTab />;
|
return <ArticleSupplierTab article={articleData} />;
|
||||||
case ARTICLE_TAB.seo:
|
case ARTICLE_TAB.seo:
|
||||||
return <ArticleSeoTab article={articleData} />;
|
return <ArticleSeoTab article={articleData} />;
|
||||||
case ARTICLE_TAB.label:
|
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 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() {
|
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 (
|
return (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Box sx={{ width: '100%' }}>
|
||||||
Supplier
|
{/* Header Bar */}
|
||||||
</Typography>
|
<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