abort and search

This commit is contained in:
seb
2026-09-19 08:14:11 +02:00
parent abe078ff05
commit d3b4743b66
9 changed files with 690 additions and 76 deletions

View File

@@ -116,13 +116,14 @@ export class ApiClient {
* Returns an async generator that yields SSE event objects: * Returns an async generator that yields SSE event objects:
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object } * { 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(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', { const res = await fetch('/api/delivery/parse', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: formData, body: formData,
signal,
}); });
if (!res.ok) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
@@ -138,28 +139,56 @@ export class ApiClient {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
let currentEvent = 'message'; let currentEvent = 'message';
while (true) {
const { done, value } = await reader.read(); const onAbort = () => {
if (done) break; try {
buf += decoder.decode(value, { stream: true }); reader.cancel();
const lines = buf.split('\n'); } catch { /* ignore */ }
buf = lines.pop(); // keep incomplete last line };
for (const line of lines) { if (signal) {
if (line.startsWith('event:')) { if (signal.aborted) {
currentEvent = line.slice(6).trim(); onAbort();
} else if (line.startsWith('data:')) { return;
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
} }
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) { parsePdfSse(file, opts) {
return this.parseSse(file); return this.parseSse(file, opts);
}, },
}; };

View File

@@ -710,13 +710,14 @@ export const delivery = {
* Accepts PDF, TXT or CSV document (max 50 MB) * Accepts PDF, TXT or CSV document (max 50 MB)
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error * Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
*/ */
async *parseSse(file) { async *parseSse(file, { signal } = {}) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', { const res = await fetch('/api/delivery/parse', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: formData, body: formData,
signal,
}); });
if (!res.ok) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
@@ -732,23 +733,51 @@ export const delivery = {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
let currentEvent = 'message'; let currentEvent = 'message';
while (true) {
const { done, value } = await reader.read(); const onAbort = () => {
if (done) break; try {
buf += decoder.decode(value, { stream: true }); reader.cancel();
const lines = buf.split('\n'); } catch { /* ignore */ }
buf = lines.pop(); };
for (const line of lines) { if (signal) {
if (line.startsWith('event:')) { if (signal.aborted) {
currentEvent = line.slice(6).trim(); onAbort();
} else if (line.startsWith('data:')) { return;
const raw = line.slice(5).trim(); }
let data; signal.addEventListener('abort', onAbort, { once: true });
try { data = JSON.parse(raw); } catch { data = raw; } }
yield { event: currentEvent, data };
currentEvent = 'message'; 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 */ }
} }
}, },
}; };

View 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 &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}
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>
);
}
}

View File

@@ -59,6 +59,7 @@ class ManifestForm extends PureComponent {
this.handleSave = this.handleSave.bind(this); this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this); this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -78,6 +79,10 @@ class ManifestForm extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
} }
async loadPartiesOnly() { async loadPartiesOnly() {
@@ -210,15 +215,21 @@ class ManifestForm extends PureComponent {
async handlePdfUpload(file) { async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return; if (!file || this.state.pdfUploading) return;
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({ this.setState({
pdfUploading: true, pdfUploading: true,
pdfDialogOpen: true, pdfDialogOpen: true,
pdfFileName: file.name, pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null }, pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
}); });
const appendMessage = (msg) => { const appendMessage = (msg) => {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
...prev.pdfDialogData, ...prev.pdfDialogData,
@@ -231,8 +242,8 @@ class ManifestForm extends PureComponent {
const gen = this.context?.delivery?.parsePdfSse; const gen = this.context?.delivery?.parsePdfSse;
if (!gen) throw new Error('parsePdfSse nicht verfügbar.'); if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) { for await (const { event, data } of this.context.delivery.parsePdfSse(file, { signal: abortController.signal })) {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
switch (event) { switch (event) {
case 'status': case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) }); appendMessage({ type: 'status', text: data.message ?? String(data) });
@@ -267,6 +278,17 @@ class ManifestForm extends PureComponent {
} }
} catch (err) { } catch (err) {
if (this._cancelled) return; 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); notifyError(err);
this.setState((prev) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
@@ -276,6 +298,9 @@ class ManifestForm extends PureComponent {
}, },
})); }));
} finally { } finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) { if (!this._cancelled) {
this.setState((prev) => ({ this.setState((prev) => ({
pdfUploading: false, 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() { handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false }); if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: false });
} }
async handleSave() { async handleSave() {
@@ -454,6 +500,7 @@ class ManifestForm extends PureComponent {
filename={pdfFileName} filename={pdfFileName}
data={pdfDialogData} data={pdfDialogData}
onClose={this.handleClosePdfDialog} onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/> />
</> </>
); );

View File

@@ -1,4 +1,5 @@
import { Fragment, PureComponent } from 'react'; import { Fragment, PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; 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 Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import { LINE_COLUMNS } from './manifestConfig'; import { LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto'; import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
class ManifestFormLine extends PureComponent { class ManifestFormLine extends PureComponent {
constructor(props) { constructor(props) {
@@ -23,6 +26,7 @@ class ManifestFormLine extends PureComponent {
this.handleRemove = this.handleRemove.bind(this); this.handleRemove = this.handleRemove.bind(this);
this.handleBlur = this.handleBlur.bind(this); this.handleBlur = this.handleBlur.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this); this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
} }
handleRemove() { handleRemove() {
@@ -42,6 +46,13 @@ class ManifestFormLine extends PureComponent {
} }
} }
handleSearchArticle() {
const { line, onSearchArticle } = this.props;
if (onSearchArticle) {
onSearchArticle(line);
}
}
renderField(column) { renderField(column) {
const { id, align, type } = column; const { id, align, type } = column;
const { line, saving, onChange } = this.props; const { line, saving, onChange } = this.props;
@@ -151,6 +162,9 @@ class ManifestFormLine extends PureComponent {
{/* Internal Article SKU */} {/* Internal Article SKU */}
{article.sku && ( {article.sku && (
<Chip <Chip
component={RouterLink}
to={artikelDetailPath(article)}
clickable
size="small" size="small"
label={`Art.-Nr.: ${article.sku}`} label={`Art.-Nr.: ${article.sku}`}
sx={{ sx={{
@@ -161,6 +175,11 @@ class ManifestFormLine extends PureComponent {
color: 'primary.dark', color: 'primary.dark',
border: '1px solid', border: '1px solid',
borderColor: 'primary.200', borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}} }}
/> />
)} )}
@@ -196,24 +215,6 @@ class ManifestFormLine extends PureComponent {
</Typography> </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) */} {/* Stored Purchase Price (Netto) */}
{priceNet != null && ( {priceNet != null && (
<Typography <Typography
@@ -258,6 +259,28 @@ class ManifestFormLine extends PureComponent {
Artikel anlegen Artikel anlegen
</Button> </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> </Box>
); );
} }

View File

@@ -30,6 +30,7 @@ class ManifestLines extends PureComponent {
loadingLineKeys = {}, loadingLineKeys = {},
onSkuBlur, onSkuBlur,
onCreateArticle, onCreateArticle,
onSearchArticle,
} = this.props; } = this.props;
return ( return (
@@ -78,6 +79,7 @@ class ManifestLines extends PureComponent {
onRemove={onRemoveLine} onRemove={onRemoveLine}
onSkuBlur={onSkuBlur} onSkuBlur={onSkuBlur}
onCreateArticle={onCreateArticle} onCreateArticle={onCreateArticle}
onSearchArticle={onSearchArticle}
/> />
); );
})} })}

View File

@@ -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; 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 hasResult = Boolean(result);
const isTextDoc = const isTextDoc =
filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv'); filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv');
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={streaming ? undefined : onClose} onClose={handleAbortOrClose}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
aria-labelledby="pdf-dialog-title" aria-labelledby="pdf-dialog-title"
@@ -121,7 +123,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{error ? ( {aborted ? (
<ErrorOutlinedIcon color="warning" />
) : error ? (
<ErrorOutlinedIcon color="error" /> <ErrorOutlinedIcon color="error" />
) : streaming ? ( ) : streaming ? (
<CircularProgress size={22} /> <CircularProgress size={22} />
@@ -134,6 +138,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
<Typography variant="subtitle1" component="div" fontWeight={600}> <Typography variant="subtitle1" component="div" fontWeight={600}>
{streaming {streaming
? (isTextDoc ? 'Dokument wird analysiert…' : 'PDF wird analysiert…') ? (isTextDoc ? 'Dokument wird analysiert…' : 'PDF wird analysiert…')
: aborted
? 'Analyse abgebrochen'
: error : error
? 'Analyse fehlgeschlagen' ? 'Analyse fehlgeschlagen'
: (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')} : (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')}
@@ -146,9 +152,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</Box> </Box>
<IconButton <IconButton
aria-label="close" aria-label="close"
onClick={onClose} onClick={handleAbortOrClose}
size="small" size="small"
disabled={streaming}
sx={{ color: (theme) => theme.palette.grey[500] }} sx={{ color: (theme) => theme.palette.grey[500] }}
> >
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
@@ -156,9 +161,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</DialogTitle> </DialogTitle>
<DialogContent dividers sx={{ p: 2 }}> <DialogContent dividers sx={{ p: 2 }}>
{/* Error */} {/* Error or Aborted Alert */}
{error && ( {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> <Typography variant="body2" fontWeight={600}>{error}</Typography>
</Alert> </Alert>
)} )}
@@ -167,7 +172,7 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
<EventLog messages={messages} streaming={streaming} /> <EventLog messages={messages} streaming={streaming} />
{/* Success notification */} {/* Success notification */}
{hasResult && !streaming && !error && ( {hasResult && !streaming && !error && !aborted && (
<Alert severity="success" sx={{ mt: 2 }}> <Alert severity="success" sx={{ mt: 2 }}>
Daten erfolgreich in das Formular übernommen. Daten erfolgreich in das Formular übernommen.
</Alert> </Alert>
@@ -175,9 +180,21 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 1.5, px: 2 }}> <DialogActions sx={{ p: 1.5, px: 2 }}>
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}> {streaming ? (
Schließen <Button
</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> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -6,6 +6,7 @@ export { default as ManifestFormLine } from './ManifestFormLine';
export { default as Attachment } from './Attachment'; export { default as Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList'; export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog'; export { default as TextEditorDialog } from './TextEditorDialog';
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId'; export { withManifestId } from './withManifestId';
export { export {
manifestConfig, manifestConfig,

View File

@@ -19,6 +19,7 @@ import {
lineFromApi, lineFromApi,
manifestConfig, manifestConfig,
TextEditorDialog, TextEditorDialog,
ArticleSearchDialog,
} from '../../components/manifest'; } from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog'; import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig'; import { loadParties } from '../../components/manifest/manifestConfig';
@@ -50,6 +51,8 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '', pdfFileName: '',
textEditorOpen: false, textEditorOpen: false,
textEditorAttachment: null, textEditorAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
}; };
this._cancelled = false; this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
@@ -65,6 +68,7 @@ class LieferungNeuPage extends PureComponent {
this.handlePdfRescan = this.handlePdfRescan.bind(this); this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this); this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this); this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this); this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this); this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
@@ -72,6 +76,9 @@ class LieferungNeuPage extends PureComponent {
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this); this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
this.handleSkuBlur = this.handleSkuBlur.bind(this); this.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleCreateArticleForLine = this.handleCreateArticleForLine.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() { componentDidMount() {
@@ -106,6 +113,10 @@ class LieferungNeuPage extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
if (this._skuDebounceTimers) { if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout); Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null; 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() { handleAddLine() {
this.automat.actions.addLineitem(); this.automat.actions.addLineitem();
} }
@@ -451,15 +523,21 @@ class LieferungNeuPage extends PureComponent {
} }
async runParse(file) { async runParse(file) {
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({ this.setState({
pdfUploading: true, pdfUploading: true,
pdfDialogOpen: true, pdfDialogOpen: true,
pdfFileName: file.name, pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null }, pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
}); });
const appendMessage = (msg) => { const appendMessage = (msg) => {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
...prev.pdfDialogData, ...prev.pdfDialogData,
@@ -472,8 +550,8 @@ class LieferungNeuPage extends PureComponent {
const parser = this.context?.delivery?.parseSse; const parser = this.context?.delivery?.parseSse;
if (!parser) throw new Error('parseSse nicht verfügbar.'); if (!parser) throw new Error('parseSse nicht verfügbar.');
for await (const { event, data } of parser(file)) { for await (const { event, data } of parser(file, { signal: abortController.signal })) {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
switch (event) { switch (event) {
case 'status': case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) }); appendMessage({ type: 'status', text: data.message ?? String(data) });
@@ -580,6 +658,17 @@ class LieferungNeuPage extends PureComponent {
} }
} catch (err) { } catch (err) {
if (this._cancelled) return; 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) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
...prev.pdfDialogData, ...prev.pdfDialogData,
@@ -588,6 +677,9 @@ class LieferungNeuPage extends PureComponent {
}, },
})); }));
} finally { } finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) { if (!this._cancelled) {
this.setState((prev) => ({ this.setState((prev) => ({
pdfUploading: false, pdfUploading: false,
@@ -607,8 +699,30 @@ class LieferungNeuPage extends PureComponent {
await this.runParse(file); 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() { handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false }); if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: false });
} }
async ensureAttachmentFile(attachment) { async ensureAttachmentFile(attachment) {
@@ -929,6 +1043,7 @@ class LieferungNeuPage extends PureComponent {
onRemoveLine={this.handleRemoveLine} onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()} onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine} onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
/> />
</> </>
)} )}
@@ -952,6 +1067,7 @@ class LieferungNeuPage extends PureComponent {
filename={pdfFileName} filename={pdfFileName}
data={pdfDialogData} data={pdfDialogData}
onClose={this.handleClosePdfDialog} onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/> />
<TextEditorDialog <TextEditorDialog
open={this.state.textEditorOpen} open={this.state.textEditorOpen}
@@ -961,6 +1077,12 @@ class LieferungNeuPage extends PureComponent {
onSave={this.handleSaveTextEditor} onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded} onContentLoad={this.handleTextEditorContentLoaded}
/> />
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
</> </>
); );
} }