Files
dashfront/src/components/manifest/ArticleSearchDialog.jsx
2026-10-04 22:47:26 +02:00

355 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &bdquo;{query}&ldquo; 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>
);
}
}