abort and search
This commit is contained in:
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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.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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user