355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
import { PureComponent } from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import Button from '@mui/material/Button';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
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 IconButton from '@mui/material/IconButton';
|
||
import InputAdornment from '@mui/material/InputAdornment';
|
||
import List from '@mui/material/List';
|
||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||
import ListItemButton from '@mui/material/ListItemButton';
|
||
import ListItemText from '@mui/material/ListItemText';
|
||
import TextField from '@mui/material/TextField';
|
||
import Typography from '@mui/material/Typography';
|
||
import Chip from '@mui/material/Chip';
|
||
import Alert from '@mui/material/Alert';
|
||
import CloseIcon from '@mui/icons-material/Close';
|
||
import SearchIcon from '@mui/icons-material/Search';
|
||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||
import LinkIcon from '@mui/icons-material/Link';
|
||
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||
import ArticlePhoto from '../article/ArticlePhoto';
|
||
import { BrandLabel } from '../brand';
|
||
|
||
export default class ArticleSearchDialog extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
this.state = {
|
||
query: '',
|
||
articles: [],
|
||
loading: false,
|
||
error: null,
|
||
assigningSku: null,
|
||
};
|
||
this.handleQueryChange = this.handleQueryChange.bind(this);
|
||
this.handleClearQuery = this.handleClearQuery.bind(this);
|
||
this.handleSelect = this.handleSelect.bind(this);
|
||
}
|
||
|
||
componentDidMount() {
|
||
if (this.props.open) {
|
||
this.initFromLine();
|
||
void this.loadArticles();
|
||
}
|
||
}
|
||
|
||
componentDidUpdate(prevProps) {
|
||
if (!prevProps.open && this.props.open) {
|
||
this.initFromLine();
|
||
void this.loadArticles();
|
||
}
|
||
}
|
||
|
||
initFromLine() {
|
||
const { line } = this.props;
|
||
const initialQuery = line?.name?.trim() || line?.sku?.trim() || '';
|
||
this.setState({
|
||
query: initialQuery,
|
||
assigningSku: null,
|
||
error: null,
|
||
});
|
||
}
|
||
|
||
async loadArticles() {
|
||
this.setState({ loading: true, error: null });
|
||
try {
|
||
const list = await articleIndexAutomat.actions.ensureLoaded();
|
||
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
|
||
} catch (err) {
|
||
console.error('Fehler beim Laden des Artikel-Index:', err);
|
||
this.setState({
|
||
loading: false,
|
||
error: err?.message || 'Artikel-Index konnte nicht geladen werden.',
|
||
});
|
||
}
|
||
}
|
||
|
||
handleQueryChange(e) {
|
||
this.setState({ query: e.target.value });
|
||
}
|
||
|
||
handleClearQuery() {
|
||
this.setState({ query: '' });
|
||
}
|
||
|
||
async handleSelect(article) {
|
||
const { onAssign, line } = this.props;
|
||
if (!onAssign || !article?.sku) return;
|
||
|
||
if (article.isParent || article.is_parent) {
|
||
this.setState({ error: 'Ein Vaterartikel kann nicht auf Belege gebucht werden.' });
|
||
return;
|
||
}
|
||
|
||
this.setState({ assigningSku: article.sku, error: null });
|
||
try {
|
||
await onAssign(article, line);
|
||
} catch (err) {
|
||
this.setState({
|
||
assigningSku: null,
|
||
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
|
||
});
|
||
}
|
||
}
|
||
|
||
render() {
|
||
const { open, onClose, line } = this.props;
|
||
const { query, articles, loading, error, assigningSku } = this.state;
|
||
|
||
const bookableArticles = articles.filter((a) => !a.isParent && !a.is_parent);
|
||
const trimmed = query.trim().toLowerCase();
|
||
const filtered = trimmed
|
||
? bookableArticles.filter((a) => {
|
||
const skuMatch = a.sku?.toLowerCase().includes(trimmed);
|
||
const nameMatch = a.name?.toLowerCase().includes(trimmed);
|
||
const eanMatch = Array.isArray(a.eans)
|
||
? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
|
||
: false;
|
||
return skuMatch || nameMatch || eanMatch;
|
||
})
|
||
: bookableArticles.slice(0, 50);
|
||
|
||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onClose={assigningSku ? undefined : onClose}
|
||
maxWidth="sm"
|
||
fullWidth
|
||
aria-labelledby="article-search-dialog-title"
|
||
>
|
||
<DialogTitle
|
||
id="article-search-dialog-title"
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
pb: 1,
|
||
}}
|
||
>
|
||
<Box>
|
||
<Typography variant="h6" component="div" fontWeight={600}>
|
||
Existierenden Artikel zuordnen
|
||
</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
Lieferanten-SKU: <strong>{rawSku || '–'}</strong>
|
||
{line?.name ? ` · ${line.name}` : ''}
|
||
</Typography>
|
||
</Box>
|
||
<IconButton
|
||
size="small"
|
||
aria-label="Schließen"
|
||
disabled={Boolean(assigningSku)}
|
||
onClick={onClose}
|
||
>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={{ p: 2 }}>
|
||
{error && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{error}
|
||
</Alert>
|
||
)}
|
||
|
||
<TextField
|
||
autoFocus
|
||
fullWidth
|
||
size="small"
|
||
placeholder="Artikel nach SKU, Name oder EAN suchen…"
|
||
value={query}
|
||
onChange={this.handleQueryChange}
|
||
disabled={Boolean(assigningSku)}
|
||
slotProps={{
|
||
input: {
|
||
startAdornment: (
|
||
<InputAdornment position="start">
|
||
<SearchIcon fontSize="small" color="action" />
|
||
</InputAdornment>
|
||
),
|
||
endAdornment: query ? (
|
||
<InputAdornment position="end">
|
||
<IconButton size="small" onClick={this.handleClearQuery} edge="end">
|
||
<CloseIcon sx={{ fontSize: 16 }} />
|
||
</IconButton>
|
||
</InputAdornment>
|
||
) : null,
|
||
},
|
||
}}
|
||
sx={{ mb: 1.5 }}
|
||
/>
|
||
|
||
{loading ? (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
|
||
<CircularProgress size={20} />
|
||
<Typography variant="body2" color="text.secondary">
|
||
Artikel werden geladen…
|
||
</Typography>
|
||
</Box>
|
||
) : filtered.length === 0 ? (
|
||
<Box sx={{ textAlign: 'center', py: 5 }}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Keine Artikel für „{query}“ gefunden.
|
||
</Typography>
|
||
</Box>
|
||
) : (
|
||
<List
|
||
dense
|
||
disablePadding
|
||
sx={{
|
||
maxHeight: 380,
|
||
overflowY: 'auto',
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
borderRadius: 1,
|
||
}}
|
||
>
|
||
{filtered.map((article) => {
|
||
const photo = article.photos?.[0] || article.articlepic?.[0];
|
||
const fullHash = photo?.hash || photo?.fullHash;
|
||
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||
const thumbW = photo?.thumbw || photo?.thumbW;
|
||
const thumbH = photo?.thumbh || photo?.thumbH;
|
||
const hasPhoto = fullHash != null || thumbHash != null;
|
||
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
|
||
const isAssigning = assigningSku === article.sku;
|
||
|
||
return (
|
||
<ListItemButton
|
||
key={article.id || article.sku}
|
||
divider
|
||
disabled={Boolean(assigningSku)}
|
||
onClick={() => this.handleSelect(article)}
|
||
sx={{
|
||
py: 1,
|
||
px: 1.5,
|
||
'&:hover': {
|
||
bgcolor: 'action.hover',
|
||
},
|
||
}}
|
||
>
|
||
<ListItemAvatar sx={{ minWidth: 44 }}>
|
||
<Box
|
||
sx={{
|
||
width: 34,
|
||
height: 34,
|
||
borderRadius: 0.75,
|
||
overflow: 'hidden',
|
||
bgcolor: '#e2e8f0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
>
|
||
{hasPhoto ? (
|
||
<ArticlePhoto
|
||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||
size={34}
|
||
/>
|
||
) : (
|
||
<Inventory2OutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||
)}
|
||
</Box>
|
||
</ListItemAvatar>
|
||
<ListItemText
|
||
primary={
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Chip
|
||
size="small"
|
||
label={article.sku}
|
||
sx={{
|
||
height: 18,
|
||
fontSize: '0.68rem',
|
||
fontWeight: 600,
|
||
bgcolor: 'primary.50',
|
||
color: 'primary.dark',
|
||
}}
|
||
/>
|
||
<Typography variant="body2" fontWeight={600} noWrap sx={{ maxWidth: 300 }}>
|
||
{article.name || '–'}
|
||
</Typography>
|
||
</Box>
|
||
}
|
||
secondary={
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
|
||
{(article.brand?.name || article.brandName) && (
|
||
<BrandLabel
|
||
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||
name={article.brand?.name || article.brandName}
|
||
size={14}
|
||
typographyProps={{
|
||
variant: 'caption',
|
||
color: 'text.secondary',
|
||
}}
|
||
/>
|
||
)}
|
||
{primaryEan ? (
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||
>
|
||
EAN: {primaryEan}
|
||
</Typography>
|
||
) : null}
|
||
</Box>
|
||
}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
startIcon={isAssigning ? <CircularProgress size={12} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
||
disabled={Boolean(assigningSku)}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
void this.handleSelect(article);
|
||
}}
|
||
sx={{
|
||
fontSize: '0.72rem',
|
||
textTransform: 'none',
|
||
py: 0.25,
|
||
px: 1.25,
|
||
ml: 1,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{isAssigning ? 'Zuordnen…' : 'Zuordnen'}
|
||
</Button>
|
||
</ListItemButton>
|
||
);
|
||
})}
|
||
</List>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ px: 2, py: 1.5 }}>
|
||
<Button
|
||
size="small"
|
||
disabled={Boolean(assigningSku)}
|
||
onClick={onClose}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
}
|