diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 0dd5b8a..10e71bd 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -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 = { diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 29b9499..2cfaa8f 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -80,11 +80,15 @@ export class StorageManager extends ApiClient { #articleWatchers = new Map(); /** @type {Map void>>} sku → history refreshers */ #historyWatchers = new Map(); + /** @type {Map 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-`. + * @param {string} sku + * @param {(suppliers: Array) => void} onSuppliers + * @returns {{ unwatch: () => void, refresh: () => Promise }} + */ + 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); } diff --git a/src/api/index.js b/src/api/index.js index db9699f..fff5468 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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) + diff --git a/src/components/article/ArticleSupplierDialog.jsx b/src/components/article/ArticleSupplierDialog.jsx new file mode 100644 index 0000000..23aace6 --- /dev/null +++ b/src/components/article/ArticleSupplierDialog.jsx @@ -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 ( + +
+ + {isEdit ? 'Lieferant bearbeiten' : 'Lieferant zuweisen'} + + + + + {isEdit ? ( + + + Lieferant + + + {item.supplierName ?? `Lieferant #${item.entityId}`} + + + ) : ( + opt.name ?? `Lieferant #${opt.id}`} + value={selectedSupplier} + onChange={(_e, val) => this.setState({ entityId: val ? val.id : null })} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(opt, val) => opt.id === val.id} + /> + )} + + this.setState({ sku: e.target.value })} + placeholder="z. B. LF-09412" + helperText="Wird beim Abgleich von Lieferscheinen / Wareneingängen genutzt" + /> + + + this.setState({ pu: e.target.value })} + sx={{ flex: 1 }} + /> + + this.setState({ stock: e.target.value })} + sx={{ flex: 1 }} + helperText="Optional (in Stk / Mengeneinheit)" + /> + + + + this.setState({ netPrice: e.target.value })} + placeholder="0,00" + InputProps={{ + endAdornment: ( + € + ), + }} + sx={{ flex: 1 }} + helperText="Optionaler Einkaufspreis (EK)" + /> + + + this.setState({ taxRate: Number(e.target.value) }) + } + sx={{ width: { sm: 160 } }} + disabled={!netPrice} + > + {taxOptions.map((rate) => ( + + {rate} % + + ))} + + + + + + + + + +
+
+ ); + } +} diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index ec4530b..56078c3 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -155,7 +155,7 @@ class ArticleTileTab extends PureComponent { case ARTICLE_TAB.history: return ; case ARTICLE_TAB.supplier: - return ; + return ; case ARTICLE_TAB.seo: return ; case ARTICLE_TAB.label: diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index e85fcd0..48eec92 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -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 ( + + + + ); + } + + const assignedEntityIds = assignedSuppliers.map((s) => s.entityId); + return ( - - Supplier - + + {/* Header Bar */} + + + + Zugeordnete Lieferanten ({assignedSuppliers.length}) + + + Bezugsquellen, Lieferanten-Artikelnr. und Konditionen für diesen Artikel + + + + + + + {/* Content Table or Empty State */} + {assignedSuppliers.length === 0 ? ( + + + + Noch keine Lieferanten zugewiesen + + + Weise diesem Artikel Lieferanten zu, um Einkaufspreise, VPE und Artikelnummern für Wareneingänge zu hinterlegen. + + + + ) : ( + + + + Reihe + Lieferant + Lief.-Art.-Nr. + VPE (PU) + Lagerbestand + Einkaufspreis + + Aktionen + + + + + {assignedSuppliers.map((row, index) => { + const isBusy = busyId === row.id; + const netPrice = row.price?.net; + const taxRate = row.price?.taxRate; + + return ( + + {/* Sort Order & Reorder Controls */} + + + + {index + 1} + + + + + this.handleMove(index, -1)} + sx={{ p: 0.2 }} + > + + + + + + + this.handleMove(index, 1)} + sx={{ p: 0.2 }} + > + + + + + + + + + {/* Supplier Name */} + + {row.supplierName ?? `Lieferant #${row.entityId}`} + + + {/* Supplier SKU */} + + {row.sku ? ( + + {row.sku} + + ) : ( + + — + + )} + + + {/* PU / VPE */} + + {row.pu ?? 1} + + + {/* Stock */} + + {row.stock != null ? ( + 0 ? 'success' : 'default'} + variant="outlined" + sx={{ fontWeight: 500, height: 22, fontSize: '0.75rem' }} + /> + ) : ( + + — + + )} + + + {/* Purchase Price Net & Tax */} + + {netPrice != null ? ( + + + {formatPrice(netPrice)} + + {taxRate != null ? ( + + zzgl. {taxRate} % MwSt. + + ) : null} + + ) : ( + + — + + )} + + + {/* Actions */} + + + + this.handleOpenEdit(row)} + disabled={isBusy} + > + + + + + this.handleDelete(row)} + disabled={isBusy} + > + + + + + + + ); + })} + +
+ )} + + {/* Add / Edit Dialog */} + +
); } }