abort and search
This commit is contained in:
@@ -116,13 +116,14 @@ export class ApiClient {
|
||||
* Returns an async generator that yields SSE event objects:
|
||||
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
||||
*/
|
||||
parseSse: async function* (file) {
|
||||
parseSse: async function* (file, { signal } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
@@ -138,28 +139,56 @@ export class ApiClient {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // keep incomplete last line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
// blank line or comment: reset
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // keep incomplete last line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
// blank line or comment: reset
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
parsePdfSse(file) {
|
||||
return this.parseSse(file);
|
||||
parsePdfSse(file, opts) {
|
||||
return this.parseSse(file, opts);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -710,13 +710,14 @@ export const delivery = {
|
||||
* Accepts PDF, TXT or CSV document (max 50 MB)
|
||||
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
|
||||
*/
|
||||
async *parseSse(file) {
|
||||
async *parseSse(file, { signal } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
@@ -732,23 +733,51 @@ export const delivery = {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class ManifestForm extends PureComponent {
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -78,6 +79,10 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadPartiesOnly() {
|
||||
@@ -210,15 +215,21 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
this._parseAbortController = abortController;
|
||||
|
||||
this.setState({
|
||||
pdfUploading: true,
|
||||
pdfDialogOpen: true,
|
||||
pdfFileName: file.name,
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
|
||||
});
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -231,8 +242,8 @@ class ManifestForm extends PureComponent {
|
||||
const gen = this.context?.delivery?.parsePdfSse;
|
||||
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
|
||||
|
||||
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) {
|
||||
if (this._cancelled) return;
|
||||
for await (const { event, data } of this.context.delivery.parsePdfSse(file, { signal: abortController.signal })) {
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
switch (event) {
|
||||
case 'status':
|
||||
appendMessage({ type: 'status', text: data.message ?? String(data) });
|
||||
@@ -267,6 +278,17 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
if (abortController.signal.aborted || err.name === 'AbortError') {
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
@@ -276,6 +298,9 @@ class ManifestForm extends PureComponent {
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
if (this._parseAbortController === abortController) {
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (!this._cancelled) {
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
@@ -287,9 +312,30 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleAbortParse() {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
pdfDialogData: prev.pdfDialogData
|
||||
? {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
handleClosePdfDialog() {
|
||||
this.setState({ pdfDialogOpen: false });
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState({ pdfDialogOpen: false, pdfUploading: false });
|
||||
}
|
||||
|
||||
async handleSave() {
|
||||
@@ -454,6 +500,7 @@ class ManifestForm extends PureComponent {
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, PureComponent } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
@@ -13,9 +14,11 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
constructor(props) {
|
||||
@@ -23,6 +26,7 @@ class ManifestFormLine extends PureComponent {
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||
this.handleSearchArticle = this.handleSearchArticle.bind(this);
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
@@ -42,6 +46,13 @@ class ManifestFormLine extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchArticle() {
|
||||
const { line, onSearchArticle } = this.props;
|
||||
if (onSearchArticle) {
|
||||
onSearchArticle(line);
|
||||
}
|
||||
}
|
||||
|
||||
renderField(column) {
|
||||
const { id, align, type } = column;
|
||||
const { line, saving, onChange } = this.props;
|
||||
@@ -151,6 +162,9 @@ class ManifestFormLine extends PureComponent {
|
||||
{/* Internal Article SKU */}
|
||||
{article.sku && (
|
||||
<Chip
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(article)}
|
||||
clickable
|
||||
size="small"
|
||||
label={`Art.-Nr.: ${article.sku}`}
|
||||
sx={{
|
||||
@@ -161,6 +175,11 @@ class ManifestFormLine extends PureComponent {
|
||||
color: 'primary.dark',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.200',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.100',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -196,24 +215,6 @@ class ManifestFormLine extends PureComponent {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Stock */}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Bestand: ${supplierArticle.stock ?? 0}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
{/* Packaging Unit (PU) */}
|
||||
{supplierArticle.pu != null && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`VPE: ${supplierArticle.pu}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stored Purchase Price (Netto) */}
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
@@ -258,6 +259,28 @@ class ManifestFormLine extends PureComponent {
|
||||
Artikel anlegen
|
||||
</Button>
|
||||
)}
|
||||
{this.props.onSearchArticle && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={this.handleSearchArticle}
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
minWidth: 'auto',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.50',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Artikel suchen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class ManifestLines extends PureComponent {
|
||||
loadingLineKeys = {},
|
||||
onSkuBlur,
|
||||
onCreateArticle,
|
||||
onSearchArticle,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -78,6 +79,7 @@ class ManifestLines extends PureComponent {
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -92,19 +92,21 @@ function EventLog({ messages, streaming }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
export default function ManifestPdfDialog({ open, filename, data, onClose, onAbort }) {
|
||||
if (!open) return null;
|
||||
|
||||
const { streaming = false, messages = [], result = null, error = null } = data ?? {};
|
||||
const { streaming = false, messages = [], result = null, error = null, aborted = false } = data ?? {};
|
||||
const hasResult = Boolean(result);
|
||||
|
||||
const isTextDoc =
|
||||
filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv');
|
||||
|
||||
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={streaming ? undefined : onClose}
|
||||
onClose={handleAbortOrClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
aria-labelledby="pdf-dialog-title"
|
||||
@@ -121,7 +123,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
{aborted ? (
|
||||
<ErrorOutlinedIcon color="warning" />
|
||||
) : error ? (
|
||||
<ErrorOutlinedIcon color="error" />
|
||||
) : streaming ? (
|
||||
<CircularProgress size={22} />
|
||||
@@ -134,6 +138,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
<Typography variant="subtitle1" component="div" fontWeight={600}>
|
||||
{streaming
|
||||
? (isTextDoc ? 'Dokument wird analysiert…' : 'PDF wird analysiert…')
|
||||
: aborted
|
||||
? 'Analyse abgebrochen'
|
||||
: error
|
||||
? 'Analyse fehlgeschlagen'
|
||||
: (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')}
|
||||
@@ -146,9 +152,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
</Box>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
onClick={handleAbortOrClose}
|
||||
size="small"
|
||||
disabled={streaming}
|
||||
sx={{ color: (theme) => theme.palette.grey[500] }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
@@ -156,9 +161,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 2 }}>
|
||||
{/* Error */}
|
||||
{/* Error or Aborted Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" icon={<ErrorOutlinedIcon />} sx={{ mb: 2 }}>
|
||||
<Alert severity={aborted ? 'warning' : 'error'} icon={aborted ? undefined : <ErrorOutlinedIcon />} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" fontWeight={600}>{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -167,7 +172,7 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
<EventLog messages={messages} streaming={streaming} />
|
||||
|
||||
{/* Success notification */}
|
||||
{hasResult && !streaming && !error && (
|
||||
{hasResult && !streaming && !error && !aborted && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Daten erfolgreich in das Formular übernommen.
|
||||
</Alert>
|
||||
@@ -175,9 +180,21 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 1.5, px: 2 }}>
|
||||
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}>
|
||||
Schließen
|
||||
</Button>
|
||||
{streaming ? (
|
||||
<Button
|
||||
onClick={onAbort || onClose}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<CloseIcon />}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} variant="contained" size="small">
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ export { default as ManifestFormLine } from './ManifestFormLine';
|
||||
export { default as Attachment } from './Attachment';
|
||||
export { default as AttachmentList } from './AttachmentList';
|
||||
export { default as TextEditorDialog } from './TextEditorDialog';
|
||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||
export { withManifestId } from './withManifestId';
|
||||
export {
|
||||
manifestConfig,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
lineFromApi,
|
||||
manifestConfig,
|
||||
TextEditorDialog,
|
||||
ArticleSearchDialog,
|
||||
} from '../../components/manifest';
|
||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
||||
@@ -50,6 +51,8 @@ class LieferungNeuPage extends PureComponent {
|
||||
pdfFileName: '',
|
||||
textEditorOpen: false,
|
||||
textEditorAttachment: null,
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
@@ -65,6 +68,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||
this.handlePdfDelete = this.handlePdfDelete.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
||||
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
|
||||
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
|
||||
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
|
||||
@@ -72,6 +76,9 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
|
||||
this.handleSkuBlur = this.handleSkuBlur.bind(this);
|
||||
this.handleCreateArticleForLine = this.handleCreateArticleForLine.bind(this);
|
||||
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
|
||||
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
|
||||
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -106,6 +113,10 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (this._skuDebounceTimers) {
|
||||
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
|
||||
this._skuDebounceTimers = null;
|
||||
@@ -311,6 +322,67 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenArticleSearchForLine(line) {
|
||||
this.setState({
|
||||
articleSearchDialogOpen: true,
|
||||
articleSearchLine: line,
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseArticleSearch() {
|
||||
this.setState({
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
});
|
||||
}
|
||||
|
||||
async handleAssignArticleForLine(article, line) {
|
||||
const { partyId } = this.state;
|
||||
if (!partyId) {
|
||||
enqueueSnackbar('Bitte wählen Sie zuerst einen Lieferanten aus.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const internalSku = article?.sku;
|
||||
if (!internalSku) {
|
||||
enqueueSnackbar('Artikel hat keine SKU.', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const supplierSku = line?.sku;
|
||||
|
||||
try {
|
||||
await this.context.article.addSupplier(internalSku, {
|
||||
entityId: partyId,
|
||||
sku: supplierSku || undefined,
|
||||
pu: line?.pu ? Number(line.pu) : undefined,
|
||||
stock: line?.amount ? Number(line.amount) : undefined,
|
||||
netPrice: line?.netprice ? Number(line.netprice) : undefined,
|
||||
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
|
||||
});
|
||||
|
||||
if (supplierSku) {
|
||||
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
||||
} else {
|
||||
this.automat.actions.fetchSupplierArticles(partyId);
|
||||
}
|
||||
|
||||
enqueueSnackbar(
|
||||
`Lieferant erfolgreich dem Artikel "${internalSku}" zugeordnet.`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
|
||||
this.setState({
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Zuordnen des Artikels:', err);
|
||||
notifyError(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
handleAddLine() {
|
||||
this.automat.actions.addLineitem();
|
||||
}
|
||||
@@ -451,15 +523,21 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
|
||||
async runParse(file) {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
this._parseAbortController = abortController;
|
||||
|
||||
this.setState({
|
||||
pdfUploading: true,
|
||||
pdfDialogOpen: true,
|
||||
pdfFileName: file.name,
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
|
||||
});
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -472,8 +550,8 @@ class LieferungNeuPage extends PureComponent {
|
||||
const parser = this.context?.delivery?.parseSse;
|
||||
if (!parser) throw new Error('parseSse nicht verfügbar.');
|
||||
|
||||
for await (const { event, data } of parser(file)) {
|
||||
if (this._cancelled) return;
|
||||
for await (const { event, data } of parser(file, { signal: abortController.signal })) {
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
switch (event) {
|
||||
case 'status':
|
||||
appendMessage({ type: 'status', text: data.message ?? String(data) });
|
||||
@@ -580,6 +658,17 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
if (abortController.signal.aborted || err.name === 'AbortError') {
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -588,6 +677,9 @@ class LieferungNeuPage extends PureComponent {
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
if (this._parseAbortController === abortController) {
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (!this._cancelled) {
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
@@ -607,8 +699,30 @@ class LieferungNeuPage extends PureComponent {
|
||||
await this.runParse(file);
|
||||
}
|
||||
|
||||
handleAbortParse() {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
pdfDialogData: prev.pdfDialogData
|
||||
? {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
handleClosePdfDialog() {
|
||||
this.setState({ pdfDialogOpen: false });
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState({ pdfDialogOpen: false, pdfUploading: false });
|
||||
}
|
||||
|
||||
async ensureAttachmentFile(attachment) {
|
||||
@@ -929,6 +1043,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onSave={() => void this.handleSave()}
|
||||
onCreateArticle={this.handleCreateArticleForLine}
|
||||
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -952,6 +1067,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
<TextEditorDialog
|
||||
open={this.state.textEditorOpen}
|
||||
@@ -961,6 +1077,12 @@ class LieferungNeuPage extends PureComponent {
|
||||
onSave={this.handleSaveTextEditor}
|
||||
onContentLoad={this.handleTextEditorContentLoaded}
|
||||
/>
|
||||
<ArticleSearchDialog
|
||||
open={this.state.articleSearchDialogOpen}
|
||||
line={this.state.articleSearchLine}
|
||||
onClose={this.handleCloseArticleSearch}
|
||||
onAssign={this.handleAssignArticleForLine}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user