487 lines
17 KiB
JavaScript
487 lines
17 KiB
JavaScript
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 { article as articleApi } from '../../../api';
|
|
import { notifyError } from '../../../utils/notify';
|
|
import { formatPrice } from '../../../utils/price';
|
|
import supplierAutomat from '../../../automats/supplierAutomat';
|
|
import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat';
|
|
import sseAutomat from '../../../automats/sseAutomat';
|
|
import companyAutomat from '../../../automats/companyAutomat';
|
|
import ArticleSupplierDialog from '../ArticleSupplierDialog';
|
|
|
|
export default class ArticleSupplierTab extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
loading: true,
|
|
assignedSuppliers: [],
|
|
companySuppliers: [],
|
|
company: null,
|
|
dialogOpen: false,
|
|
editItem: null,
|
|
busyId: null,
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
|
company: state.current ?? null,
|
|
}));
|
|
this.initData();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.article?.sku !== this.props.article?.sku) {
|
|
this.initData();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.unsubSuppliers?.();
|
|
this.unsubSuppliers = null;
|
|
this.unwatchSignal?.();
|
|
this.unwatchSignal = null;
|
|
this.unsubCompany?.();
|
|
this.unsubCompany = null;
|
|
}
|
|
|
|
async initData() {
|
|
const sku = this.props.article?.sku;
|
|
if (!sku) return;
|
|
|
|
// 1. Subscribe to the keyed supplier cache and the article SSE signal
|
|
this.unsubSuppliers?.();
|
|
const handle = articleSupplierAutomat.key(String(sku));
|
|
this.unsubSuppliers = handle.subscribe(this, (item) => {
|
|
this.setState({
|
|
assignedSuppliers: Array.isArray(item.data) ? item.data : [],
|
|
loading: false,
|
|
});
|
|
});
|
|
void articleSupplierAutomat.load(String(sku)).catch((err) => {
|
|
if (this.unsubSuppliers) notifyError(err);
|
|
});
|
|
|
|
this.unwatchSignal?.();
|
|
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
|
|
void articleSupplierAutomat.reload(String(sku)).catch(() => {});
|
|
});
|
|
|
|
// 2. Fetch available company suppliers
|
|
try {
|
|
const companySuppliers = await supplierAutomat.actions.load();
|
|
this.setState({ companySuppliers: companySuppliers ?? [] });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
this.setState({ loading: false });
|
|
}
|
|
}
|
|
|
|
/** Re-fetches the assigned suppliers for the current article. */
|
|
refreshSuppliers = async () => {
|
|
const sku = this.props.article?.sku;
|
|
if (!sku) return;
|
|
await articleSupplierAutomat.reload(String(sku));
|
|
};
|
|
|
|
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 articleApi.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 articleApi.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 articleApi.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 articleApi.orderSuppliers(sku, ids);
|
|
await this.refreshSuppliers();
|
|
} catch (err) {
|
|
notifyError(err);
|
|
await this.refreshSuppliers();
|
|
} finally {
|
|
this.setState({ busyId: null });
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
loading,
|
|
assignedSuppliers,
|
|
companySuppliers,
|
|
company,
|
|
dialogOpen,
|
|
editItem,
|
|
busyId,
|
|
} = this.state;
|
|
|
|
if (this.props.article?.isParent) {
|
|
return (
|
|
<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.4 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5, color: 'text.secondary' }}>
|
|
Lieferanten sind für Vaterartikel deaktiviert
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
|
Lieferanten, Einkaufspreise und Lieferanten-Artikelnummern werden für jede Variante separat gepflegt.
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
}
|