feat(article): implement articlesupplier editor tab

- ApiClient: add getSuppliers, addSupplier, updateSupplier,
  orderSuppliers, deleteSupplier on article namespace
- api/index.js: expose same methods in legacy http client
- StorageManager: add #supplierWatchers Map and watchArticleSuppliers()
  backed by article-<sku> SSE signal; wired up as article.supplierWatch
- ArticleTileTab: pass article prop to ArticleSupplierTab
- ArticleSupplierDialog (new): dialog to assign / edit a supplier link
  (supplier autocomplete, Lief.-SKU, VPE, stock, EK Netto + MwSt.)
- ArticleSupplierTab: full supplier management table with reordering
  (up/down arrows → PUT /supplier/order), inline edit and delete,
  stock badge, purchase price display and empty state
This commit is contained in:
seb
2026-09-17 23:37:41 +02:00
parent 6e72ae84bd
commit 795043fe99
6 changed files with 833 additions and 10 deletions

View File

@@ -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 = {

View File

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

View File

@@ -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) +

View File

@@ -0,0 +1,232 @@
import { PureComponent } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import InputAdornment from '@mui/material/InputAdornment';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { companyTaxRateOptions } from '../../utils/articleDetail';
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
function parseNumber(value, min = 0) {
if (value === '' || value == null) return null;
const num = Number(String(value).trim().replace(',', '.'));
return Number.isFinite(num) && num >= min ? num : null;
}
export default class ArticleSupplierDialog extends PureComponent {
constructor(props) {
super(props);
this.state = this.initState(props);
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState(this.initState(this.props));
}
}
initState(props) {
const item = props.item;
const defaultTaxRate =
props.company?.taxrate != null ? Number(props.company.taxrate) : 19;
return {
entityId: item?.entityId ? Number(item.entityId) : null,
sku: item?.sku ?? '',
pu: item?.pu != null ? String(item.pu) : '1',
stock: item?.stock != null ? String(item.stock) : '',
netPrice:
item?.price?.net != null ? formatPriceAmount(item.price.net) : '',
taxRate:
item?.price?.taxRate != null
? Number(item.price.taxRate)
: defaultTaxRate,
submitting: false,
};
}
handleSubmit = async (e) => {
e.preventDefault();
const { entityId, sku, pu, stock, netPrice, taxRate } = this.state;
const isEdit = Boolean(this.props.item?.id);
if (!isEdit && !entityId) return;
const parsedPu = parseNumber(pu, 0.0001);
if (parsedPu == null || parsedPu <= 0) {
return;
}
const parsedStock = parseNumber(stock, 0);
const parsedNet = netPrice !== '' ? parsePriceInput(netPrice) : null;
this.setState({ submitting: true });
try {
await this.props.onSave({
id: this.props.item?.id,
entityId: isEdit ? this.props.item.entityId : entityId,
sku: sku.trim() || null,
pu: parsedPu,
stock: parsedStock,
netPrice: parsedNet,
taxRate: parsedNet != null ? taxRate : undefined,
});
} finally {
this.setState({ submitting: false });
}
};
render() {
const {
open,
onClose,
suppliers = [],
item,
company,
assignedEntityIds = [],
} = this.props;
const { entityId, sku, pu, stock, netPrice, taxRate, submitting } =
this.state;
const isEdit = Boolean(item?.id);
const availableSuppliers = isEdit
? suppliers
: suppliers.filter(
(s) => !assignedEntityIds.includes(s.id) || s.id === entityId,
);
const selectedSupplier =
suppliers.find((s) => s.id === entityId) ?? null;
const taxOptions = companyTaxRateOptions(company, taxRate);
const parsedPu = parseNumber(pu, 0.0001);
const puValid = parsedPu != null && parsedPu > 0;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<form onSubmit={this.handleSubmit}>
<DialogTitle sx={{ fontWeight: 600 }}>
{isEdit ? 'Lieferant bearbeiten' : 'Lieferant zuweisen'}
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2.5}>
{isEdit ? (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferant
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{item.supplierName ?? `Lieferant #${item.entityId}`}
</Typography>
</Box>
) : (
<Autocomplete
options={availableSuppliers}
getOptionLabel={(opt) => opt.name ?? `Lieferant #${opt.id}`}
value={selectedSupplier}
onChange={(_e, val) => this.setState({ entityId: val ? val.id : null })}
renderInput={(params) => (
<TextField
{...params}
label="Lieferant auswählen"
required
placeholder="Lieferant suchen…"
/>
)}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
/>
)}
<TextField
label="Lieferanten-Artikelnummer (SKU)"
value={sku}
onChange={(e) => this.setState({ sku: e.target.value })}
placeholder="z. B. LF-09412"
helperText="Wird beim Abgleich von Lieferscheinen / Wareneingängen genutzt"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Verpackungseinheit (VPE / PU)"
type="number"
required
error={!puValid}
helperText={!puValid ? 'Muss größer als 0 sein' : undefined}
inputProps={{ step: 'any', min: 0.0001 }}
value={pu}
onChange={(e) => this.setState({ pu: e.target.value })}
sx={{ flex: 1 }}
/>
<TextField
label="Bestand beim Lieferanten"
type="number"
inputProps={{ step: 'any', min: 0 }}
value={stock}
onChange={(e) => this.setState({ stock: e.target.value })}
sx={{ flex: 1 }}
helperText="Optional (in Stk / Mengeneinheit)"
/>
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="Einkaufspreis Netto"
value={netPrice}
onChange={(e) => this.setState({ netPrice: e.target.value })}
placeholder="0,00"
InputProps={{
endAdornment: (
<InputAdornment position="end">€</InputAdornment>
),
}}
sx={{ flex: 1 }}
helperText="Optionaler Einkaufspreis (EK)"
/>
<TextField
select
label="MwSt.-Satz"
value={taxRate}
onChange={(e) =>
this.setState({ taxRate: Number(e.target.value) })
}
sx={{ width: { sm: 160 } }}
disabled={!netPrice}
>
{taxOptions.map((rate) => (
<MenuItem key={rate} value={rate}>
{rate} %
</MenuItem>
))}
</TextField>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit" disabled={submitting}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={submitting || (!isEdit && !entityId) || !puValid}
>
{isEdit ? 'Aktualisieren' : 'Hinzufügen'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

View File

@@ -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:

View File

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