abort and search
This commit is contained in:
344
src/components/manifest/ArticleSearchDialog.jsx
Normal file
344
src/components/manifest/ArticleSearchDialog.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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 { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
|
||||
export default class ArticleSearchDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
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 {
|
||||
let list = [];
|
||||
if (this.context?.articleIndexPromise) {
|
||||
list = await this.context.articleIndexPromise;
|
||||
} else if (this.context?.article?.index) {
|
||||
const res = await this.context.article.index();
|
||||
list = res?.articles || [];
|
||||
}
|
||||
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;
|
||||
|
||||
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 trimmed = query.trim().toLowerCase();
|
||||
const filtered = trimmed
|
||||
? articles.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;
|
||||
})
|
||||
: articles.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}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<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={
|
||||
primaryEan ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
EAN: {primaryEan}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user