This commit is contained in:
seb
2026-10-16 13:10:58 +02:00
parent 7bc1da4de6
commit d851c6e1e9
10 changed files with 326 additions and 445 deletions

View File

@@ -7,7 +7,7 @@ description: >-
# Dash API Routes Overview (All)
Overview of all 249 API endpoints across all modules.
Overview of all 248 API endpoints across all modules.
### Notation
@@ -406,18 +406,6 @@ _none_
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T35[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T9`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
- Events:
- `status`: `{ "message": "…" }`
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
- `tool_call`: `{ "name": "output_manifest" }`
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
- `progress`: `ManifestResult`
- `result`: `ManifestResult`
- `error`: `{ "message": "…" }`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int`
- Returns: `204 void`

View File

@@ -96,83 +96,4 @@ export const delivery = {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
*/
// #region POST /api/delivery/parse — parseSse
async *parseSse(file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let 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 */ }
}
},
// #endregion
};

View File

@@ -327,6 +327,24 @@ const actions = {
void chatAutomat.actions.loadConversations();
},
/**
* Open the chat drawer and append text / prompt to the chat composer.
* @param {string} prompt
*/
insertPrompt(prompt = '') {
chatOpenedThisSession = true;
chatAutomat.setState((prev) => ({
isOpen: true,
sseEstablished: true,
pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${prompt}` : prompt,
}));
ensureCompanyEventsConnected();
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
chatAutomat.actions.startDraft();
}
void chatAutomat.actions.loadConversations();
},
/**
* Start an implicit new conversation with the first preset model. Nothing is
* created on the server until the first message is sent.

View File

@@ -199,9 +199,9 @@ export default class ChatDrawer extends PureComponent {
activeTurns: state.activeTurns || [],
};
if (state.pendingInput != null) {
patch.inputText = state.pendingInput;
patch.stagedAttachments = [];
patch.stagedReferences = [];
patch.inputText = this.state.inputText
? `${this.state.inputText}\n\n${state.pendingInput}`
: state.pendingInput;
queueMicrotask(() => {
if (chatAutomat.state.pendingInput != null) {
chatAutomat.setState({ pendingInput: null });

View File

@@ -5,12 +5,12 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import SkeleHashImage from '../settings/SkeleHashImage';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { notifyErrorMessage } from '../../utils/notify';
import chatAutomat from '../../automats/chatAutomat';
import {
SCRIM_ACTION,
SCRIM_ATTACHMENT,
@@ -61,18 +61,40 @@ export class Attachment extends PureComponent {
handleRescan = (e) => {
e?.stopPropagation?.();
if (this.props.hasArticles) {
notifyErrorMessage(
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
return;
}
const { attachment, onRescan } = this.props;
if (attachment && onRescan) {
onRescan(attachment);
}
};
handleMentionInChat = async (e) => {
e?.stopPropagation?.();
const { attachment, onMentionInChat } = this.props;
if (!attachment) return;
if (onMentionInChat) {
onMentionInChat(attachment);
return;
}
let textContent = attachment.text || '';
if (!textContent && (attachment.file instanceof Blob || attachment.file instanceof File)) {
try {
textContent = await attachment.file.text();
} catch (err) {
console.error('Fehler beim Lesen des Texts:', err);
}
}
const trimmed = (textContent || '').trim();
if (!trimmed) return;
const title = attachment.name ? `Text aus „${attachment.name}“:` : 'Text:';
const quote = `${title}\n\`\`\`\n${trimmed}\n\`\`\``;
chatAutomat.actions.insertPrompt(quote);
};
handleDelete = (e) => {
e?.stopPropagation?.();
const { attachment, onDelete } = this.props;
@@ -237,51 +259,51 @@ export class Attachment extends PureComponent {
}}
onClick={(e) => e.stopPropagation()}
>
{kind === 'delivery' && Boolean(onRescan) && (isPdf || isText) ? (
<Tooltip
title={
hasArticles
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
: 'Neu analysieren'
}
{kind === 'delivery' && Boolean(onRescan) && isPdf ? (
<IconButton
size="small"
disabled={disabled}
onClick={this.handleRescan}
aria-label="Zu Text"
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': {
bgcolor: SCRIM_HEAVY,
},
}}
>
<span
style={{ display: 'inline-block' }}
onClick={
hasArticles
? (e) => {
e.stopPropagation();
notifyErrorMessage(
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
}
: undefined
}
>
<IconButton
size="small"
disabled={disabled || hasArticles}
onClick={this.handleRescan}
sx={{
bgcolor: SCRIM_ACTION,
color: hasArticles ? VEIL_DISABLED : WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
cursor: hasArticles ? 'not-allowed' : 'pointer',
'&:hover': {
bgcolor: hasArticles ? SCRIM_ACTION : SCRIM_HEAVY,
},
}}
>
<SyncIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<TextSnippetOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{isText ? (
<IconButton
size="small"
disabled={disabled}
onClick={this.handleMentionInChat}
aria-label="Im Chat erwähnen"
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': {
bgcolor: SCRIM_HEAVY,
},
}}
>
<ChatBubbleOutlineRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
) : null}
<IconButton
size="small"
disabled={disabled}
onClick={this.handleDelete}
aria-label="Löschen"
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,

View File

@@ -6,7 +6,7 @@ import Attachment from './Attachment';
export class AttachmentList extends PureComponent {
render() {
const { attachments, onRescan, onDelete, onClick, disabled, hasArticles, kind, docId } = this.props;
const { attachments, onRescan, onDelete, onClick, onMentionInChat, disabled, hasArticles, kind, docId } = this.props;
if (!Array.isArray(attachments) || attachments.length === 0) {
return null;
}
@@ -31,6 +31,7 @@ export class AttachmentList extends PureComponent {
onRescan={onRescan}
onDelete={onDelete}
onClick={onClick}
onMentionInChat={onMentionInChat}
disabled={disabled}
hasArticles={hasArticles}
/>

View File

@@ -40,11 +40,14 @@ class ManifestFormLine extends PureComponent {
cachedArticle: sku ? getArticle(sku) : null,
loadingArticle: false,
fallbackSuppliers: [],
editingField: null,
};
this.handleRemove = this.handleRemove.bind(this);
this.handleMoveUp = this.handleMoveUp.bind(this);
this.handleMoveDown = this.handleMoveDown.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEdit = this.handleStartEdit.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
@@ -134,7 +137,25 @@ class ManifestFormLine extends PureComponent {
this.props.onMove?.(this.props.line.key, 1);
}
handleStartEdit(columnId) {
const { saving } = this.props;
const isDisabled = saving || (columnId === 'sku' && Boolean(this.props.isOnShipment));
if (isDisabled) return;
if (this.state.editingField !== columnId) {
this.setState({ editingField: columnId });
}
}
handleKeyDown(e) {
if (e.key === 'Enter' || e.key === 'Escape') {
e.target.blur();
}
}
handleBlur(e, columnId) {
if (this.state.editingField === columnId) {
this.setState({ editingField: null });
}
if (columnId === 'sku' && this.props.onSkuBlur) {
this.props.onSkuBlur(this.props.line.key, e.target.value);
}
@@ -229,6 +250,8 @@ class ManifestFormLine extends PureComponent {
renderField(column, colIdx, totalCols) {
const { id, align, type } = column;
const { line, saving, onChange, columns } = this.props;
const isFirstLine = Boolean(this.props.isFirstLine ?? (this.props.index === 0));
const isLastLine = Boolean(this.props.isLastLine);
if (id === 'pos') {
const posVal =
@@ -240,8 +263,10 @@ class ManifestFormLine extends PureComponent {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 38,
height: isFirstLine ? 40 : 41,
minHeight: isFirstLine ? 40 : 41,
maxHeight: isFirstLine ? 40 : 41,
mt: isFirstLine ? 0 : '-1px',
fontWeight: 700,
fontSize: '0.82rem',
color: 'text.secondary',
@@ -297,65 +322,197 @@ class ManifestFormLine extends PureComponent {
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
const isLast = colIdx === totalCols - 1;
const topLeftRadius = isFirstInput && isFirstLine ? 4 : 0;
const topRightRadius = isLast && isFirstLine ? 4 : 0;
const bottomRightRadius = isLast && isLastLine ? 4 : 0;
const bottomLeftRadius = isFirstInput && isLastLine ? 4 : 0;
const borderRadius = `${topLeftRadius}px ${topRightRadius}px ${bottomRightRadius}px ${bottomLeftRadius}px`;
const { dirtyFields } = this.props;
const isFieldDirty = Boolean(
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
);
return (
<TextField
size="small"
fullWidth
type={type}
value={val ?? ''}
error={isError}
disabled={saving || (id === 'sku' && Boolean(this.props.isOnShipment))}
title={id === 'sku' && this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden' : undefined}
autoComplete="off"
onChange={(e) => onChange(line.key, id, e.target.value)}
onBlur={(e) => this.handleBlur(e, id)}
slotProps={{
htmlInput: {
inputMode: type === 'number' ? 'decimal' : 'text',
style: { textAlign: align },
autoComplete: 'off',
step: type === 'number' ? 'any' : undefined,
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
const isEditing = this.state.editingField === id;
const isDisabled = saving || (id === 'sku' && Boolean(this.props.isOnShipment));
const titleText = id === 'sku' && this.props.isOnShipment
? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden'
: undefined;
if (isEditing) {
return (
<TextField
autoFocus
size="small"
type={type}
value={val ?? ''}
error={isError}
disabled={isDisabled}
title={titleText}
autoComplete="off"
onChange={(e) => onChange(line.key, id, e.target.value)}
onBlur={(e) => this.handleBlur(e, id)}
onKeyDown={this.handleKeyDown}
slotProps={{
htmlInput: {
inputMode: type === 'number' ? 'decimal' : 'text',
style: { textAlign: align },
autoComplete: 'off',
step: type === 'number' ? 'any' : undefined,
},
}}
sx={{
position: 'relative',
ml: isFirstInput ? 0 : '-1px',
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
}),
'&:hover': {
zIndex: 1,
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
mt: isFirstLine ? 0 : '-1px',
height: isFirstLine ? 40 : 41,
zIndex: 3,
'& .MuiOutlinedInput-root': {
height: isFirstLine ? 40 : 41,
borderRadius,
width: '100%',
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
}),
'&:hover': {
zIndex: 4,
},
'&.Mui-focused': {
zIndex: 5,
},
},
'&.Mui-focused': {
zIndex: 2,
},
},
...(isFieldDirty && {
zIndex: 1,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.23)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'primary.main',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.87)',
},
'& input[type=number]': {
MozAppearance: 'textfield',
},
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
{
WebkitAppearance: 'none',
margin: 0,
},
}}
/>
);
}
const hasValue = val != null && String(val).trim() !== '';
return (
<Box
tabIndex={isDisabled ? -1 : 0}
role={isDisabled ? undefined : 'button'}
onClick={() => this.handleStartEdit(id)}
onFocus={() => this.handleStartEdit(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleStartEdit(id);
}
}}
title={titleText}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start',
height: isFirstLine ? 40 : 41,
minHeight: isFirstLine ? 40 : 41,
maxHeight: isFirstLine ? 40 : 41,
boxSizing: 'border-box',
position: 'relative',
ml: isFirstInput ? 0 : '-1px',
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
mt: isFirstLine ? 0 : '-1px',
px: '14px',
py: 0,
borderRadius,
border: '1px solid',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.23)',
cursor: isDisabled ? 'default' : 'text',
touchAction: 'manipulation',
userSelect: 'none',
zIndex: 1,
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
zIndex: 2,
}),
'& input[type=number]': {
MozAppearance: 'textfield',
},
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
{
WebkitAppearance: 'none',
margin: 0,
...(isError && !isFieldDirty && {
bgcolor: 'rgba(211, 47, 47, 0.04)',
borderColor: 'error.main',
zIndex: 2,
}),
'&:hover': isDisabled
? undefined
: {
bgcolor: isFieldDirty
? 'rgba(237, 108, 2, 0.14)'
: isError
? 'rgba(211, 47, 47, 0.08)'
: 'action.hover',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.87)',
zIndex: 3,
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: -2,
zIndex: 4,
},
}}
/>
>
<Typography
variant="body2"
sx={{
textAlign: align,
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.875rem',
lineHeight: '20px',
fontWeight: id === 'sku' ? 600 : 400,
color: isError
? 'error.main'
: hasValue
? 'text.primary'
: 'text.disabled',
fontStyle: 'normal',
}}
>
{hasValue ? String(val) : '—'}
</Typography>
</Box>
);
}
@@ -979,7 +1136,11 @@ class ManifestFormLine extends PureComponent {
sx={{
p: 0,
width: col.width,
verticalAlign: 'middle',
minWidth: col.width,
height: 40,
verticalAlign: 'top',
position: 'relative',
overflow: 'visible',
}}
>
{this.renderField(col, colIdx, columns.length)}

View File

@@ -243,6 +243,7 @@ class ManifestHeader extends PureComponent {
onPdfDelete,
onPdfClick,
onAttachmentClick,
onMentionInChat,
onDateChange,
onNumberChange,
onExternalNumberChange,
@@ -456,6 +457,7 @@ class ManifestHeader extends PureComponent {
onRescan={onPdfRescan}
onDelete={onPdfDelete}
onClick={onAttachmentClick ?? onPdfClick}
onMentionInChat={onMentionInChat}
disabled={saving || pdfUploading}
hasArticles={hasArticles}
/>

View File

@@ -36,7 +36,6 @@ import {
ArticleSearchDialog,
BookDeliveryDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
import { article as articleApi, delivery as deliveryApi } from '../../api';
import { getCategoryState } from '../../automats/categoryAutomat';
@@ -68,9 +67,6 @@ class LieferungNeuPage extends PureComponent {
partiesLoading: true,
saving: false,
pdfUploading: false,
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
textEditorOpen: false,
textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition,
@@ -105,8 +101,6 @@ class LieferungNeuPage extends PureComponent {
this.handlePdfUpload = this.handlePdfUpload.bind(this);
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.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
@@ -159,10 +153,6 @@ 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;
@@ -301,7 +291,6 @@ class LieferungNeuPage extends PureComponent {
handleCreateSupplierFromPdf(supplierName) {
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
this.handleClosePdfDialog();
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
}
@@ -635,9 +624,6 @@ class LieferungNeuPage extends PureComponent {
};
this.automat.actions.addAttachment(textAttachment);
}
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
await this.runParse(file);
}
async handlePdfRescan(attachment) {
@@ -697,8 +683,6 @@ class LieferungNeuPage extends PureComponent {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
}
}
await this.runParse(attachment.file);
}
handlePdfDelete(attachment) {
@@ -706,227 +690,6 @@ class LieferungNeuPage extends PureComponent {
this.automat.actions.removeAttachment(attachment.id);
}
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, aborted: false },
});
const appendMessage = (msg) => {
if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
messages: [...(prev.pdfDialogData?.messages ?? []), msg],
},
}));
};
try {
for await (const { event, data } of deliveryApi.parseSse(file, { signal: abortController.signal })) {
if (this._cancelled || abortController.signal.aborted) return;
switch (event) {
case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) });
break;
case 'llm_call':
appendMessage({
type: 'llm_call',
text: `LLM-Aufruf: ${data.model} (${data.promptSize} tokens, ${data.messages} msg)`,
});
break;
case 'tool_call':
appendMessage({ type: 'tool_call', text: `Tool-Aufruf: ${data.name}` });
break;
case 'usage':
appendMessage({
type: 'usage',
text: `Token-Verbrauch: ${data.input} in / ${data.cached} cached / ${data.output} out — $${(data.cost ?? 0).toFixed(4)}`,
});
break;
case 'progress':
this.setState((prev) => ({
pdfDialogData: { ...prev.pdfDialogData, result: data },
}));
break;
case 'result':
case 'done': {
const result = data?.delivery ?? data;
const rawItems = Array.isArray(result?.lines)
? result.lines
: Array.isArray(result?.items?.items)
? result.items.items
: null;
const newLines = Array.isArray(rawItems)
? rawItems.map((l, idx) => {
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
: '';
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '';
return {
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: taxVal,
tax: taxVal,
netprice: netVal,
net: netVal,
};
})
: null;
this.setState((prev) => ({
pdfDialogData: { ...prev.pdfDialogData, streaming: false, result },
}));
const docDate = result?.date ?? result?.basedata?.date;
let supplierId = result?.supplierId ?? result?.basedata?.kLieferant;
const extNum = result?.externalNumber ?? result?.basedata?.manifestNr;
const docType = result?.type ?? result?.basedata?.manifestType;
const notes = Array.isArray(result?.notes) ? result.notes : [];
const parsedSupplierName = (
result?.supplierName ??
result?.basedata?.lieferantName ??
result?.lieferantName ??
result?.basedata?.supplierName ??
''
).trim();
// If supplierId is null, try matching supplier by name from loaded delivery entities
if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = parsedSupplierName.toLowerCase();
const matched = this.state.deliveryEntities.find(
(e) =>
e.name &&
(e.name.trim().toLowerCase() === nameToMatch ||
e.name.toLowerCase().includes(nameToMatch) ||
nameToMatch.includes(e.name.toLowerCase())),
);
if (matched) supplierId = matched.id;
}
const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
this.setState({ unmatchedSupplierName });
this.automat.actions.setUnmatchedSupplierName(unmatchedSupplierName);
if (docDate) this.automat.actions.setDate(docDate);
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
if (extNum != null) {
this.automat.actions.setExternalNumber(String(extNum));
}
if (docType) this.automat.actions.setType(docType);
if (newLines && newLines.length > 0) {
this.automat.actions.setLineitems(newLines);
}
if (notes.length > 0) {
this.automat.actions.setNotes(notes);
}
// Supply whole article list to GET /supplier/{id}/articles
const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
}
break;
}
case 'error':
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
error: data.message ?? 'Analyse fehlgeschlagen.',
},
}));
break;
default:
break;
}
}
} 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,
streaming: false,
error: err.message ?? 'Netzwerkfehler bei Analyse.',
},
}));
} finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) {
this.setState((prev) => ({
pdfUploading: false,
pdfDialogData: prev.pdfDialogData
? { ...prev.pdfDialogData, streaming: false }
: null,
}));
}
}
}
async runPdfParse(file) {
await this.runParse(file);
}
async runTxtParse(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() {
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: false });
}
async ensureAttachmentFile(attachment) {
if (!attachment) return null;
const hasContent =
@@ -1127,7 +890,7 @@ class LieferungNeuPage extends PureComponent {
isViewerOpen() {
return Boolean(
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
);
}
@@ -1244,9 +1007,6 @@ class LieferungNeuPage extends PureComponent {
partiesLoading,
saving,
pdfUploading,
pdfDialogOpen,
pdfDialogData,
pdfFileName,
deliveryEntities,
date,
partyId,
@@ -1376,15 +1136,7 @@ class LieferungNeuPage extends PureComponent {
</PanelTile>
)}
</PanelTileGrid>
<ManifestPdfDialog
open={pdfDialogOpen}
filename={pdfFileName}
data={pdfDialogData}
unmatchedSupplierName={this.state.unmatchedSupplierName}
onCreateSupplier={this.handleCreateSupplierFromPdf}
onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
@@ -1456,8 +1208,8 @@ class LieferungNeuPage extends PureComponent {
status === 'booked'
? 'success.light'
: status === 'partial'
? 'warning.light'
: 'divider',
? 'warning.light'
: 'divider',
}}
>
<Stack spacing={2}>
@@ -1554,8 +1306,8 @@ class LieferungNeuPage extends PureComponent {
{status === 'booked'
? 'Buchungsdetails anzeigen'
: status === 'partial'
? 'Restliche Mengen einbuchen'
: 'Ins Lager buchen'}
? 'Restliche Mengen einbuchen'
: 'Ins Lager buchen'}
</Button>
</Box>

View File

@@ -6,6 +6,7 @@ import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import pc from 'picocolors';
import qrcode from 'qrcode';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const useHttps = process.env.VITE_HTTPS === '1';
@@ -94,6 +95,7 @@ const sharedResolve = {
const sharedServer = {
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
host: true,
port: useHttps ? 4308 : 4209,
https: httpsOptions(),
allowedHosts: true,
@@ -266,6 +268,20 @@ function pidReporterPlugin() {
server.config.logger.info(
` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`,
);
const networkUrls = server.resolvedUrls?.network ?? [];
const primaryUrl =
networkUrls.find((u) => !u.includes('172.17.') && !u.includes('169.254.')) ||
networkUrls[0] ||
server.resolvedUrls?.local?.[0];
if (primaryUrl) {
qrcode.toString(primaryUrl, { type: 'terminal', small: true }, (err, qr) => {
if (!err && qr) {
server.config.logger.info(
`\n ${pc.bold('Scan to open on mobile')} ${pc.dim(`(${primaryUrl})`)}:\n` + qr,
);
}
});
}
};
};