From d3b4743b66f0350a1cc2055df1010b095e5b9528 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 08:14:11 +0200 Subject: [PATCH] abort and search --- src/api/ApiClient.js | 69 +++- src/api/index.js | 61 +++- .../manifest/ArticleSearchDialog.jsx | 344 ++++++++++++++++++ src/components/manifest/ManifestForm.jsx | 57 ++- src/components/manifest/ManifestFormLine.jsx | 59 ++- src/components/manifest/ManifestLines.jsx | 2 + src/components/manifest/ManifestPdfDialog.jsx | 41 ++- src/components/manifest/index.js | 1 + src/pages/lieferung/neu.jsx | 132 ++++++- 9 files changed, 690 insertions(+), 76 deletions(-) create mode 100644 src/components/manifest/ArticleSearchDialog.jsx diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 5c3c9b2..31778da 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -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); }, }; diff --git a/src/api/index.js b/src/api/index.js index 504a3ae..23a8ff7 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 */ } } }, }; diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx new file mode 100644 index 0000000..4b39529 --- /dev/null +++ b/src/components/manifest/ArticleSearchDialog.jsx @@ -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 ( + + + + + Existierenden Artikel zuordnen + + + Lieferanten-SKU: {rawSku || '–'} + {line?.name ? ` · ${line.name}` : ''} + + + + + + + + + {error && ( + + {error} + + )} + + + + + ), + endAdornment: query ? ( + + + + + + ) : null, + }, + }} + sx={{ mb: 1.5 }} + /> + + {loading ? ( + + + + Artikel werden geladen… + + + ) : filtered.length === 0 ? ( + + + Keine Artikel für „{query}“ gefunden. + + + ) : ( + + {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 ( + this.handleSelect(article)} + sx={{ + py: 1, + px: 1.5, + '&:hover': { + bgcolor: 'action.hover', + }, + }} + > + + + {hasPhoto ? ( + + ) : ( + + )} + + + + + + {article.name || '–'} + + + } + secondary={ + primaryEan ? ( + + EAN: {primaryEan} + + ) : null + } + /> + + + ); + })} + + )} + + + + + + + ); + } +} diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 02d2f50..527a9e7 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -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} /> ); diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index 51c06c7..54ef881 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -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 && ( )} @@ -196,24 +215,6 @@ class ManifestFormLine extends PureComponent { )} - {/* Stock */} - - - {/* Packaging Unit (PU) */} - {supplierArticle.pu != null && ( - - )} - {/* Stored Purchase Price (Netto) */} {priceNet != null && ( )} + {this.props.onSearchArticle && ( + + )} ); } diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index a9f0105..b6f1912 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -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} /> ); })} diff --git a/src/components/manifest/ManifestPdfDialog.jsx b/src/components/manifest/ManifestPdfDialog.jsx index b155b78..6100e22 100644 --- a/src/components/manifest/ManifestPdfDialog.jsx +++ b/src/components/manifest/ManifestPdfDialog.jsx @@ -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 ( - {error ? ( + {aborted ? ( + + ) : error ? ( ) : streaming ? ( @@ -134,6 +138,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) { {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 }) { theme.palette.grey[500] }} > @@ -156,9 +161,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) { - {/* Error */} + {/* Error or Aborted Alert */} {error && ( - } sx={{ mb: 2 }}> + } sx={{ mb: 2 }}> {error} )} @@ -167,7 +172,7 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) { {/* Success notification */} - {hasResult && !streaming && !error && ( + {hasResult && !streaming && !error && !aborted && ( Daten erfolgreich in das Formular übernommen. @@ -175,9 +180,21 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) { - + {streaming ? ( + + ) : ( + + )} ); diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index 201091e..cb4c23a 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -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, diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index 4777bde..9b4ded0 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -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} /> + ); }