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