This commit is contained in:
seb
2026-10-05 10:33:18 +02:00
parent 5b4cf1683a
commit 8c5419c89e
15 changed files with 1613 additions and 320 deletions

View File

@@ -1,94 +1,32 @@
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';
import {
BORDER,
} from '../../colors';
import ArticleSearch from '../article/ArticleSearch';
export default class ArticleSearchDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
query: '',
articles: [],
loading: false,
error: null,
assigningSku: null,
error: 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();
this.setState({ assigningSku: null, error: null });
}
}
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) {
handleSelect = async (article) => {
const { onAssign, line } = this.props;
if (!onAssign || !article?.sku) return;
@@ -106,34 +44,43 @@ export default class ArticleSearchDialog extends PureComponent {
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 { assigningSku, error } = this.state;
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
const initialQuery = line?.name?.trim() || line?.sku?.trim() || '';
return (
<Dialog
open={open}
open={Boolean(open)}
onClose={assigningSku ? undefined : onClose}
maxWidth="sm"
maxWidth="md"
fullWidth
aria-labelledby="article-search-dialog-title"
PaperProps={{
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
}}
slotProps={{
paper: {
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
<DialogTitle
id="article-search-dialog-title"
@@ -142,6 +89,7 @@ export default class ArticleSearchDialog extends PureComponent {
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
flexShrink: 0,
}}
>
<Box>
@@ -163,183 +111,32 @@ export default class ArticleSearchDialog extends PureComponent {
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2 }}>
<DialogContent
dividers
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Alert severity="error" sx={{ mb: 2, flexShrink: 0 }}>
{error}
</Alert>
)}
<TextField
<ArticleSearch
mode="list"
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 }}
initialQuery={initialQuery}
allowParent={false}
busySku={assigningSku}
actionLabel={assigningSku ? 'Zuordnen…' : 'Zuordnen'}
onSelect={this.handleSelect}
/>
{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: BORDER,
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 }}>

View File

@@ -12,6 +12,7 @@ import withNavigate from '../withNavigate';
import ManifestHeader from './ManifestHeader';
import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import ArticleSearchDialog from './ArticleSearchDialog';
import {
buildManifestBody,
restoreAttachmentsFromApi,
@@ -43,6 +44,8 @@ class ManifestForm extends PureComponent {
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
articleSearchDialogOpen: false,
articleSearchLine: null,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
@@ -53,6 +56,10 @@ class ManifestForm extends PureComponent {
this.handleExternalNumberChange.bind(this);
this.handleLineChange = this.handleLineChange.bind(this);
this.handleAddLine = this.handleAddLine.bind(this);
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
this.handleSave = this.handleSave.bind(this);
@@ -205,6 +212,95 @@ class ManifestForm extends PureComponent {
this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] }));
}
handleAddArticleLine(article) {
if (!article) return;
const netVal =
article.price != null
? String(article.price)
: article.vknetto != null
? String(article.vknetto)
: article.net != null
? String(article.net)
: '';
const taxVal =
article.taxrate != null
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
const newLine = {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
amount: '1',
sku: article.sku || '',
name: article.name || '',
tax: taxVal,
taxrate: taxVal,
net: netVal,
netprice: netVal,
rebate: '',
pu: '1',
unit: article.unit || 'Stück',
};
this.setState((prev) => {
if (prev.lines.length === 1 && !prev.lines[0].sku && !prev.lines[0].name) {
return { lines: [newLine] };
}
return { lines: [...prev.lines, newLine] };
});
}
handleOpenArticleSearchForLine(line) {
this.setState({
articleSearchDialogOpen: true,
articleSearchLine: line,
});
}
handleCloseArticleSearch() {
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
}
handleAssignArticleForLine(article, targetLine) {
if (!article || !targetLine) return;
const netVal =
article.price != null
? String(article.price)
: article.vknetto != null
? String(article.vknetto)
: article.net != null
? String(article.net)
: '';
const taxVal =
article.taxrate != null
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
this.setState((prev) => ({
lines: prev.lines.map((l) => {
if (l.key !== targetLine.key) return l;
return {
...l,
sku: article.sku || l.sku,
name: article.name || l.name,
tax: taxVal || l.tax,
taxrate: taxVal || l.taxrate,
net: netVal || l.net,
netprice: netVal || l.netprice,
unit: article.unit || l.unit || 'Stück',
};
}),
articleSearchDialogOpen: false,
articleSearchLine: null,
}));
}
handleRemoveLine(key) {
this.setState((prev) => {
const next = prev.lines.filter((line) => line.key !== key);
@@ -424,6 +520,8 @@ class ManifestForm extends PureComponent {
linesColor={cfg.linesColor}
onLineChange={this.handleLineChange}
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveLine={this.handleRemoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
@@ -496,6 +594,12 @@ class ManifestForm extends PureComponent {
onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
</>
);
}

View File

@@ -13,6 +13,7 @@ import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
import { PanelTile } from 'dashgui/panels';
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
import ManifestFormLine from './ManifestFormLine';
import ArticleSearch from '../article/ArticleSearch';
class ManifestLines extends PureComponent {
render() {
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
linesColor,
onLineChange,
onAddLine,
onAddArticleLine,
onRemoveLine,
onRemoveAllLines,
columns = LINE_COLUMNS,
@@ -117,14 +119,25 @@ class ManifestLines extends PureComponent {
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
{onAddArticleLine && (
<ArticleSearch
size="small"
clearOnSelect
allowParent={false}
placeholder="Artikel suchen und hinzufügen…"
disabled={saving}
onSelect={onAddArticleLine}
sx={{ minWidth: 260, flex: 1, maxWidth: 440 }}
/>
)}
<Button
size="small"
startIcon={<AddIcon />}
disabled={saving}
onClick={onAddLine}
>
Zeile hinzufügen
{onAddArticleLine ? 'Leere Zeile' : 'Zeile hinzufügen'}
</Button>
<Button
size="small"