feat(lieferung/neu): PDF upload with SSE streaming and result dialog
- Add PDF-Upload button to ManifestHeader (delivery forms only) - New ManifestPdfDialog: live SSE event log, structured basedata, and line-items table streamed from POST /api/delivery/parsePDF - ApiClient.delivery.parsePdfSse: multipart/form-data upload, async generator yielding typed SSE events (status, llm_call, tool_call, usage, progress, result, error) - ManifestForm.handlePdfUpload: drives SSE generator, updates dialog state in real-time; dialog opens immediately on upload - Fix MUI icon imports (CheckCircle, ErrorOutlined)
This commit is contained in:
1066
artikeltabelle.csv
Normal file
1066
artikeltabelle.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -93,8 +93,56 @@ export class ApiClient {
|
|||||||
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
||||||
/** DELETE /api/delivery/{id} → 204 */
|
/** DELETE /api/delivery/{id} → 204 */
|
||||||
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
||||||
|
/**
|
||||||
|
* POST /api/delivery/parsePDF — multipart/form-data, field "pdf"
|
||||||
|
* Returns an async generator that yields SSE event objects:
|
||||||
|
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
||||||
|
*/
|
||||||
|
parsePdfSse: async function* (file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('pdf', file, file.name);
|
||||||
|
const res = await fetch('/api/delivery/parsePDF', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
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';
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
this.quote = {
|
this.quote = {
|
||||||
/** GET /api/quote → { quotes } */
|
/** GET /api/quote → { quotes } */
|
||||||
list: () => this.#json('GET', '/api/quote'),
|
list: () => this.#json('GET', '/api/quote'),
|
||||||
@@ -326,9 +374,9 @@ export class ApiClient {
|
|||||||
return buildUrl(this.#baseUrl, path);
|
return buildUrl(this.#baseUrl, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #json(method, path, { body, query, contentType, raw } = {}) {
|
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
|
||||||
const url = buildPath(path, query);
|
const url = buildPath(path, query);
|
||||||
const headers = {};
|
const headers = { ...extraHeaders };
|
||||||
let payload;
|
let payload;
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
|
async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) {
|
||||||
const url = new URL(path, window.location.origin).href;
|
const url = new URL(path, window.location.origin).href;
|
||||||
|
|
||||||
logApi('request', {
|
logApi('request', {
|
||||||
@@ -96,7 +96,7 @@ async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
|
|||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': contentType },
|
headers: { 'Content-Type': contentType, ...(extraHeaders || {}) },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
@@ -603,6 +603,19 @@ export const delivery = {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return http(`/api/delivery/${id}`, { method: 'DELETE' });
|
return http(`/api/delivery/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/delivery/parse-pdf
|
||||||
|
* Body: raw PDF bytes (Content-Type: application/pdf)
|
||||||
|
*/
|
||||||
|
parsePdf(file) {
|
||||||
|
return httpBinary('/api/delivery/parse-pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
body: file,
|
||||||
|
headers: file?.name ? { 'X-Filename': encodeURIComponent(file.name) } : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
@@ -8,13 +8,23 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import GetAppIcon from '@mui/icons-material/GetApp';
|
import GetAppIcon from '@mui/icons-material/GetApp';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import PageLayout from './PageLayout';
|
import PageLayout from './PageLayout';
|
||||||
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
||||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||||
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
|
import {
|
||||||
|
selectRenameMode,
|
||||||
|
setRenameMode,
|
||||||
|
selectCategoryMap,
|
||||||
|
selectCategoryRootId,
|
||||||
|
loadCategoryTree,
|
||||||
|
} from '../store/categorySlice';
|
||||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||||
import { article, category } from '../api';
|
import { article, category } from '../api';
|
||||||
|
import { importArtikelCsv } from '../utils/importArtikel';
|
||||||
|
import { notifyErrorMessage } from '../utils/notify';
|
||||||
|
|
||||||
function triggerDownload(blob, filename) {
|
function triggerDownload(blob, filename) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -31,7 +41,11 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const renameMode = useSelector(selectRenameMode);
|
const renameMode = useSelector(selectRenameMode);
|
||||||
|
const categories = useSelector(selectCategoryMap);
|
||||||
|
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||||
|
const importInputRef = useRef(null);
|
||||||
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
||||||
const subNav = SUB_NAV[sectionPath];
|
const subNav = SUB_NAV[sectionPath];
|
||||||
const subSegment = resolveActiveSub(pathname, sectionPath);
|
const subSegment = resolveActiveSub(pathname, sectionPath);
|
||||||
@@ -74,6 +88,49 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
triggerDownload(await article.exportZip(), 'artikel.zip');
|
triggerDownload(await article.exportZip(), 'artikel.zip');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportFile = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setExtrasAnchor(null);
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const csvText = await file.text();
|
||||||
|
const result = await importArtikelCsv(csvText, {
|
||||||
|
categories,
|
||||||
|
rootCategoryId,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
enqueueSnackbar(`Importiere Artikel… ${progress}`, {
|
||||||
|
variant: 'info',
|
||||||
|
autoHideDuration: 800,
|
||||||
|
preventDuplicate: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.ok > 0 && rootCategoryId != null) {
|
||||||
|
await dispatch(loadCategoryTree()).unwrap();
|
||||||
|
}
|
||||||
|
if (result.ok > 0) {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`${result.ok} Artikel importiert${result.skipped ? `, ${result.skipped} übersprungen` : ''}.`,
|
||||||
|
{ variant: 'success' },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
enqueueSnackbar('Keine Artikel importiert.', { variant: 'warning' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyErrorMessage(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
setExtrasAnchor(null);
|
||||||
|
importInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const titleAction = showsRenameSwitch ? (
|
const titleAction = showsRenameSwitch ? (
|
||||||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||||
<ToggleElevatedPill
|
<ToggleElevatedPill
|
||||||
@@ -86,6 +143,13 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
||||||
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv,text/plain"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
<Popover
|
<Popover
|
||||||
open={Boolean(extrasAnchor)}
|
open={Boolean(extrasAnchor)}
|
||||||
anchorEl={extrasAnchor}
|
anchorEl={extrasAnchor}
|
||||||
@@ -94,13 +158,21 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
>
|
>
|
||||||
<MenuList sx={{ py: 0.5 }}>
|
<MenuList sx={{ py: 0.5 }}>
|
||||||
<MenuItem onClick={exportArtikel}>
|
<MenuItem onClick={exportArtikel} disabled={importing}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<GetAppIcon fontSize="small" />
|
<GetAppIcon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Artikel exportieren</ListItemText>
|
<ListItemText>Artikel exportieren</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={exportKategorien}>
|
{renameMode && (
|
||||||
|
<MenuItem onClick={handleImportClick} disabled={importing}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<UploadFileIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Artikel importieren</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={exportKategorien} disabled={importing}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<GetAppIcon fontSize="small" />
|
<GetAppIcon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { notifyError } from '../../utils/notify';
|
|||||||
import withNavigate from '../withNavigate';
|
import withNavigate from '../withNavigate';
|
||||||
import ManifestHeader from './ManifestHeader';
|
import ManifestHeader from './ManifestHeader';
|
||||||
import ManifestLines from './ManifestLines';
|
import ManifestLines from './ManifestLines';
|
||||||
|
import ManifestPdfDialog from './ManifestPdfDialog';
|
||||||
import {
|
import {
|
||||||
buildManifestBody,
|
buildManifestBody,
|
||||||
dateInputValue,
|
dateInputValue,
|
||||||
@@ -38,6 +39,10 @@ class ManifestForm extends PureComponent {
|
|||||||
externalNumber: '',
|
externalNumber: '',
|
||||||
lines: [emptyLine()],
|
lines: [emptyLine()],
|
||||||
saving: false,
|
saving: false,
|
||||||
|
pdfUploading: false,
|
||||||
|
pdfDialogOpen: false,
|
||||||
|
pdfDialogData: null,
|
||||||
|
pdfFileName: '',
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
@@ -50,6 +55,8 @@ class ManifestForm extends PureComponent {
|
|||||||
this.handleAddLine = this.handleAddLine.bind(this);
|
this.handleAddLine = this.handleAddLine.bind(this);
|
||||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||||
this.handleSave = this.handleSave.bind(this);
|
this.handleSave = this.handleSave.bind(this);
|
||||||
|
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||||
|
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -185,6 +192,83 @@ class ManifestForm extends PureComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handlePdfUpload(file) {
|
||||||
|
if (!file || this.state.pdfUploading) return;
|
||||||
|
this.setState({
|
||||||
|
pdfUploading: true,
|
||||||
|
pdfDialogOpen: true,
|
||||||
|
pdfFileName: file.name,
|
||||||
|
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendMessage = (msg) => {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
pdfDialogData: {
|
||||||
|
...prev.pdfDialogData,
|
||||||
|
messages: [...(prev.pdfDialogData?.messages ?? []), msg],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gen = this.context?.delivery?.parsePdfSse;
|
||||||
|
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
|
||||||
|
|
||||||
|
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
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':
|
||||||
|
this.setState((prev) => ({
|
||||||
|
pdfDialogData: { ...prev.pdfDialogData, result: data, streaming: false },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
this.setState((prev) => ({
|
||||||
|
pdfDialogData: { ...prev.pdfDialogData, error: data.message ?? String(data), streaming: false },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
pdfDialogData: {
|
||||||
|
...(prev.pdfDialogData ?? {}),
|
||||||
|
error: err.message || String(err),
|
||||||
|
streaming: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (!this._cancelled) this.setState({ pdfUploading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
handleClosePdfDialog() {
|
||||||
|
this.setState({ pdfDialogOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
async handleSave() {
|
async handleSave() {
|
||||||
const { mode, docId } = this.props;
|
const { mode, docId } = this.props;
|
||||||
const cfg = this.cfg;
|
const cfg = this.cfg;
|
||||||
@@ -298,6 +382,10 @@ class ManifestForm extends PureComponent {
|
|||||||
number,
|
number,
|
||||||
externalNumber,
|
externalNumber,
|
||||||
saving,
|
saving,
|
||||||
|
pdfUploading,
|
||||||
|
pdfDialogOpen,
|
||||||
|
pdfDialogData,
|
||||||
|
pdfFileName,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
@@ -313,26 +401,36 @@ class ManifestForm extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTileGrid>
|
<>
|
||||||
<ManifestHeader
|
<PanelTileGrid>
|
||||||
kind={kind}
|
<ManifestHeader
|
||||||
headerColor={cfg.headerColor}
|
kind={kind}
|
||||||
dateLabel={cfg.dateLabel}
|
headerColor={cfg.headerColor}
|
||||||
partyLabel={cfg.partyLabel}
|
dateLabel={cfg.dateLabel}
|
||||||
parties={parties}
|
partyLabel={cfg.partyLabel}
|
||||||
partiesLoading={partiesLoading || status === 'loadingParties'}
|
parties={parties}
|
||||||
partyId={partyId}
|
partiesLoading={partiesLoading || status === 'loadingParties'}
|
||||||
date={date}
|
partyId={partyId}
|
||||||
number={number}
|
date={date}
|
||||||
externalNumber={externalNumber}
|
number={number}
|
||||||
saving={saving}
|
externalNumber={externalNumber}
|
||||||
onPartyChange={this.handlePartyChange}
|
saving={saving}
|
||||||
onDateChange={this.handleDateChange}
|
pdfUploading={pdfUploading}
|
||||||
onNumberChange={this.handleNumberChange}
|
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
||||||
onExternalNumberChange={this.handleExternalNumberChange}
|
onPartyChange={this.handlePartyChange}
|
||||||
|
onDateChange={this.handleDateChange}
|
||||||
|
onNumberChange={this.handleNumberChange}
|
||||||
|
onExternalNumberChange={this.handleExternalNumberChange}
|
||||||
|
/>
|
||||||
|
{status === 'ready' ? this.renderArticles() : null}
|
||||||
|
</PanelTileGrid>
|
||||||
|
<ManifestPdfDialog
|
||||||
|
open={pdfDialogOpen}
|
||||||
|
filename={pdfFileName}
|
||||||
|
data={pdfDialogData}
|
||||||
|
onClose={this.handleClosePdfDialog}
|
||||||
/>
|
/>
|
||||||
{status === 'ready' ? this.renderArticles() : null}
|
</>
|
||||||
</PanelTileGrid>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Autocomplete from '@mui/material/Autocomplete';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
||||||
@@ -9,13 +12,27 @@ import { manifestConfig, partyDisplayName } from './manifestConfig';
|
|||||||
class ManifestHeader extends PureComponent {
|
class ManifestHeader extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
this.fileInputRef = createRef();
|
||||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||||
|
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||||
|
this.handleFileChange = this.handleFileChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePartyChange(_event, option) {
|
handlePartyChange(_event, option) {
|
||||||
this.props.onPartyChange(option?.id ?? null);
|
this.props.onPartyChange(option?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleUploadClick() {
|
||||||
|
this.fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileChange(event) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
this.props.onPdfUpload?.(file);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
kind,
|
kind,
|
||||||
@@ -29,6 +46,8 @@ class ManifestHeader extends PureComponent {
|
|||||||
number,
|
number,
|
||||||
externalNumber,
|
externalNumber,
|
||||||
saving,
|
saving,
|
||||||
|
pdfUploading,
|
||||||
|
onPdfUpload,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onNumberChange,
|
onNumberChange,
|
||||||
onExternalNumberChange,
|
onExternalNumberChange,
|
||||||
@@ -44,13 +63,13 @@ class ManifestHeader extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
type="date"
|
type="date"
|
||||||
label={dateLabel}
|
label={dateLabel}
|
||||||
value={date}
|
value={date}
|
||||||
disabled={saving}
|
disabled={saving || pdfUploading}
|
||||||
onChange={(e) => onDateChange(e.target.value)}
|
onChange={(e) => onDateChange(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -64,7 +83,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
options={parties}
|
options={parties}
|
||||||
value={selected}
|
value={selected}
|
||||||
loading={Boolean(partiesLoading)}
|
loading={Boolean(partiesLoading)}
|
||||||
disabled={saving}
|
disabled={saving || pdfUploading}
|
||||||
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
onChange={this.handlePartyChange}
|
onChange={this.handlePartyChange}
|
||||||
@@ -88,7 +107,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
size="small"
|
size="small"
|
||||||
label="Nummer"
|
label="Nummer"
|
||||||
value={number}
|
value={number}
|
||||||
disabled={saving || useExternalNumber}
|
disabled={saving || pdfUploading || useExternalNumber}
|
||||||
onChange={onNumberChange}
|
onChange={onNumberChange}
|
||||||
sx={{ minWidth: 180, flex: 1 }}
|
sx={{ minWidth: 180, flex: 1 }}
|
||||||
/>
|
/>
|
||||||
@@ -97,15 +116,37 @@ class ManifestHeader extends PureComponent {
|
|||||||
size="small"
|
size="small"
|
||||||
label="Referenz"
|
label="Referenz"
|
||||||
value={externalNumber ?? ''}
|
value={externalNumber ?? ''}
|
||||||
disabled={saving}
|
disabled={saving || pdfUploading}
|
||||||
onChange={onExternalNumberChange}
|
onChange={onExternalNumberChange}
|
||||||
sx={{ minWidth: 180, flex: 1 }}
|
sx={{ minWidth: 180, flex: 1 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onPdfUpload ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
onClick={this.handleUploadClick}
|
||||||
|
sx={{ whiteSpace: 'nowrap', height: 40, alignSelf: { xs: 'stretch', sm: 'center' }, px: 2 }}
|
||||||
|
>
|
||||||
|
{pdfUploading ? 'Wird analysiert…' : 'PDF hochladen'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{onPdfUpload ? (
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={this.handleFileChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ManifestHeader;
|
export default ManifestHeader;
|
||||||
|
|
||||||
|
|||||||
273
src/components/manifest/ManifestPdfDialog.jsx
Normal file
273
src/components/manifest/ManifestPdfDialog.jsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
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 Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
|
|
||||||
|
/** Chip colour by SSE event type */
|
||||||
|
const EVENT_CHIP_COLOR = {
|
||||||
|
status: 'default',
|
||||||
|
llm_call: 'primary',
|
||||||
|
tool_call: 'secondary',
|
||||||
|
usage: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
function EventLog({ messages, streaming }) {
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
if (!messages?.length && !streaming) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', userSelect: 'none', mb: 0.5 }}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600}>
|
||||||
|
LLM-Protokoll
|
||||||
|
</Typography>
|
||||||
|
{streaming ? (
|
||||||
|
<CircularProgress size={12} sx={{ ml: 'auto' }} />
|
||||||
|
) : (
|
||||||
|
<CheckCircleIcon sx={{ ml: 'auto', fontSize: 16, color: 'success.main' }} />
|
||||||
|
)}
|
||||||
|
<IconButton size="small" sx={{ p: 0 }}>
|
||||||
|
{expanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: 180,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: (t) => (t.palette.mode === 'dark' ? 'grey.900' : 'grey.50'),
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.25 }}>
|
||||||
|
<Chip
|
||||||
|
label={msg.type}
|
||||||
|
size="small"
|
||||||
|
color={EVENT_CHIP_COLOR[msg.type] ?? 'default'}
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', height: 18, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.75rem', wordBreak: 'break-all' }}>
|
||||||
|
{msg.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{streaming && !messages.length && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Verbinde…
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseDataSection({ basedata }) {
|
||||||
|
if (!basedata) return null;
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
|
||||||
|
Kopfdaten
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||||
|
{basedata.date && <Chip size="small" label={`Datum: ${basedata.date}`} />}
|
||||||
|
{basedata.manifestNr && <Chip size="small" label={`Nr: ${basedata.manifestNr}`} />}
|
||||||
|
{basedata.manifestType && <Chip size="small" label={`Typ: ${basedata.manifestType}`} />}
|
||||||
|
{basedata.lieferantName && <Chip size="small" label={`Lieferant: ${basedata.lieferantName}`} />}
|
||||||
|
{basedata.kLieferant != null && <Chip size="small" label={`kLieferant: ${basedata.kLieferant}`} />}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemsSection({ items }) {
|
||||||
|
if (!items?.items?.length) return null;
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
|
||||||
|
Positionen ({items.items.length})
|
||||||
|
{items.pagesFrom != null && (
|
||||||
|
<span style={{ fontWeight: 400, marginLeft: 4 }}>
|
||||||
|
— Seiten {items.pagesFrom}–{items.pagesTo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ overflowX: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<Table size="small" padding="none" sx={{ minWidth: 520 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
{['Pos.', 'Artikel', 'LiefArtNr', 'Menge', 'VPE', 'Einheit', 'EK-Netto', 'MwSt'].map((h) => (
|
||||||
|
<TableCell key={h} sx={{ px: 1, py: 0.5, fontSize: '0.72rem', fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
{h}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.items.map((item, i) => (
|
||||||
|
<TableRow key={i} hover>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem' }}>{item.position ?? i + 1}</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.articleName ?? '–'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', fontFamily: 'monospace' }}>{item.liefArtNr ?? '–'}</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>{item.count}</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>{item.vpe ?? 1}</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem' }}>{item.unit ?? '–'}</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right', fontFamily: 'monospace' }}>
|
||||||
|
{item.eknetto != null ? item.eknetto.toFixed(2) : '–'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>
|
||||||
|
{item.mwst != null ? `${item.mwst} %` : '–'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const { streaming = false, messages = [], result = null, error = null } = data ?? {};
|
||||||
|
const hasResult = Boolean(result);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={streaming ? undefined : onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
aria-labelledby="pdf-dialog-title"
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
id="pdf-dialog-title"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<ErrorOutlinedIcon color="error" />
|
||||||
|
) : streaming ? (
|
||||||
|
<CircularProgress size={22} />
|
||||||
|
) : (
|
||||||
|
<PictureAsPdfIcon color="action" />
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" component="div" fontWeight={600}>
|
||||||
|
{streaming ? 'PDF wird analysiert…' : error ? 'PDF-Analyse fehlgeschlagen' : 'PDF-Analyseergebnis'}
|
||||||
|
</Typography>
|
||||||
|
{filename ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{filename}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
disabled={streaming}
|
||||||
|
sx={{ color: (theme) => theme.palette.grey[500] }}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ p: 2 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" icon={<ErrorOutlinedIcon />}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{error}</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live log */}
|
||||||
|
<EventLog messages={messages} streaming={streaming} />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{result?.notes?.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
|
||||||
|
Hinweise
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{result.notes.map((note, i) => (
|
||||||
|
<Alert key={i} severity="warning" sx={{ py: 0, '& .MuiAlert-message': { fontSize: '0.8rem' } }}>
|
||||||
|
{note}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basedata + Items */}
|
||||||
|
{hasResult && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<BaseDataSection basedata={result.basedata} />
|
||||||
|
<ItemsSection items={result.items} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No result yet and no error: show waiting state */}
|
||||||
|
{!hasResult && !error && !streaming && (
|
||||||
|
<Alert severity="info">Keine Daten extrahiert.</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ p: 1.5, px: 2 }}>
|
||||||
|
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/utils/importArtikel.js
Normal file
236
src/utils/importArtikel.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Client-side bulk import engine for the shop `artikeltabelle.csv` format.
|
||||||
|
*
|
||||||
|
* Columns: ArtNr; Name; Preis; USt; Pfand; Hersteller; Kategorie; Barcode; V/K
|
||||||
|
*
|
||||||
|
* Everything is created through the existing dashback API:
|
||||||
|
* - categories: POST /api/category ({ parentId, name })
|
||||||
|
* - brands: PUT /api/brand ({ name }) → { brand: { id } }
|
||||||
|
* - articles: POST /api/article ({ parentId, name }) → { article: { sku } }
|
||||||
|
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
|
||||||
|
* - brand: PUT /api/article/:sku/brand ({ brandId })
|
||||||
|
* - eans: PUT /api/article/:sku/eans ({ eans })
|
||||||
|
* - categories: PUT /api/article/:sku/categories({ categoryIds })
|
||||||
|
*
|
||||||
|
* SKU is auto-allocated by the server (POST /article) — the CSV `ArtNr` is used
|
||||||
|
* only as a de-duplication / reference key, not as the SKU.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseImportCsv,
|
||||||
|
parseCategoryPath,
|
||||||
|
} from './importArtikelCsv';
|
||||||
|
import { article, category, brand } from '../api';
|
||||||
|
import {
|
||||||
|
requireObject,
|
||||||
|
requireDefined,
|
||||||
|
} from './apiShape';
|
||||||
|
|
||||||
|
function logImport(message) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[import] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a category path lookup from the normalized category map (id → node). */
|
||||||
|
function buildCategoryPathIndex(categories, rootCategoryId) {
|
||||||
|
const byParent = new Map();
|
||||||
|
for (const node of Object.values(categories ?? {})) {
|
||||||
|
const key = node.parentId == null ? 'null' : String(node.parentId);
|
||||||
|
if (!byParent.has(key)) byParent.set(key, []);
|
||||||
|
byParent.get(key).push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathToId = new Map();
|
||||||
|
const rootKey = String(rootCategoryId);
|
||||||
|
const rootName = categories?.[rootKey]?.name ?? '';
|
||||||
|
|
||||||
|
const walk = (node, trail) => {
|
||||||
|
const name = (node.name ?? '').trim();
|
||||||
|
const nextTrail = [...trail, name];
|
||||||
|
const pathKey = nextTrail.join('|').toLowerCase();
|
||||||
|
if (!pathToId.has(pathKey)) {
|
||||||
|
pathToId.set(pathKey, node.id);
|
||||||
|
}
|
||||||
|
for (const child of byParent.get(String(node.id)) ?? []) {
|
||||||
|
walk(child, nextTrail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rootName) walk({ id: rootCategoryId, name: rootName, parentId: null }, []);
|
||||||
|
|
||||||
|
// Also index each node directly by its own name so a single-segment path
|
||||||
|
// that equals an existing category name resolves.
|
||||||
|
for (const node of Object.values(categories ?? {})) {
|
||||||
|
const name = (node.name ?? '').trim();
|
||||||
|
if (name) {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (!pathToId.has(key)) pathToId.set(key, node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pathToId, rootCategoryId, rootName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a category path `A | B | C` exists under root. Returns the leaf id.
|
||||||
|
* Reuses an in-memory cache so repeated paths are cheap.
|
||||||
|
*/
|
||||||
|
function createEnsureCategory(cache) {
|
||||||
|
return async function ensureCategory(segments) {
|
||||||
|
if (!Array.isArray(segments) || segments.length === 0) {
|
||||||
|
return cache.rootCategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = segments.map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (clean.length === 0) return cache.rootCategoryId;
|
||||||
|
|
||||||
|
const cacheKey = clean.join('|').toLowerCase();
|
||||||
|
if (cache.byPath.has(cacheKey)) return cache.byPath.get(cacheKey);
|
||||||
|
|
||||||
|
// Walk from root to leaf, creating missing levels.
|
||||||
|
let parentId = cache.rootCategoryId;
|
||||||
|
let trail = [];
|
||||||
|
for (const segment of clean) {
|
||||||
|
trail.push(segment);
|
||||||
|
const trailKey = trail.join('|').toLowerCase();
|
||||||
|
const existing = cache.pathToId.get(trailKey);
|
||||||
|
if (existing != null) {
|
||||||
|
parentId = existing;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Create the missing category under the current parent.
|
||||||
|
const res = await category.create({ parentId, name: segment });
|
||||||
|
requireObject(res, 'POST /api/category');
|
||||||
|
const created = res.category;
|
||||||
|
requireDefined(created.id, 'category.id');
|
||||||
|
cache.pathToId.set(trailKey, created.id);
|
||||||
|
// Note: parent of next level is this new category.
|
||||||
|
cache.byName.set(segment.toLowerCase(), created.id);
|
||||||
|
parentId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.byPath.set(cacheKey, parentId);
|
||||||
|
return parentId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a brand by name → brandId.
|
||||||
|
* PUT /api/brand upserts by name (same name ⇒ update, otherwise create).
|
||||||
|
*/
|
||||||
|
function createEnsureBrand(cache) {
|
||||||
|
return async function ensureBrand(name) {
|
||||||
|
const clean = String(name ?? '').trim();
|
||||||
|
if (!clean) return null;
|
||||||
|
const key = clean.toLowerCase();
|
||||||
|
if (cache.brandByKey.has(key)) return cache.brandByKey.get(key);
|
||||||
|
|
||||||
|
const res = await brand.upsert({ name: clean });
|
||||||
|
requireObject(res, 'PUT /api/brand');
|
||||||
|
requireObject(res.brand, 'brand');
|
||||||
|
requireDefined(res.brand.id, 'brand.id');
|
||||||
|
cache.brandByKey.set(key, res.brand.id);
|
||||||
|
return res.brand.id;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import one normalized CSV row through the API.
|
||||||
|
* Returns a summary object for the caller to aggregate.
|
||||||
|
*/
|
||||||
|
async function importRow(row, { ensureCategory, ensureBrand }) {
|
||||||
|
const name = String(row.name ?? '').trim();
|
||||||
|
if (!name) return { status: 'skipped', reason: 'name-empty' };
|
||||||
|
|
||||||
|
const leafCategoryId = await ensureCategory(row.categoryPath);
|
||||||
|
const brandId = row.brand ? await ensureBrand(row.brand) : null;
|
||||||
|
|
||||||
|
// Create the article under the leaf category.
|
||||||
|
const createRes = await article.create({ parentId: leafCategoryId, name });
|
||||||
|
requireObject(createRes, 'POST /api/article');
|
||||||
|
const created = createRes.article;
|
||||||
|
requireDefined(created, 'article');
|
||||||
|
requireDefined(created.sku, 'article.sku');
|
||||||
|
const sku = created.sku;
|
||||||
|
|
||||||
|
const applied = { sku, artNr: row.artNr || null, name };
|
||||||
|
|
||||||
|
// Price (net) + tax. CSV `Preis` is treated as net in the shop export.
|
||||||
|
if (row.price != null) {
|
||||||
|
const tax = row.tax == null ? 19 : row.tax;
|
||||||
|
await article.setPrices(sku, { prices: [{ price: row.price, tax }] });
|
||||||
|
applied.price = { net: row.price, tax };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandId != null) {
|
||||||
|
await article.setBrandIdBySku(sku, brandId);
|
||||||
|
applied.brandId = brandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ean) {
|
||||||
|
await article.setEansBySku(sku, { eans: [row.ean] });
|
||||||
|
applied.ean = row.ean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article is created under the leaf category; keep the category membership.
|
||||||
|
await article.setCategories(sku, { categoryIds: [leafCategoryId] });
|
||||||
|
applied.categoryId = leafCategoryId;
|
||||||
|
|
||||||
|
return { status: 'created', ...applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a full CSV text into the selected company.
|
||||||
|
*
|
||||||
|
* @param {string} csvText Raw CSV text.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.categories Normalized category map (id → node) from the store.
|
||||||
|
* @param {number|string} options.rootCategoryId Company root category id.
|
||||||
|
* @param {(msg: string) => void} [options.onProgress] Progress callback.
|
||||||
|
* @returns {Promise<{ok:number, skipped:number, rows:Array}>}
|
||||||
|
*/
|
||||||
|
export async function importArtikelCsv(csvText, { categories, rootCategoryId, onProgress } = {}) {
|
||||||
|
const rows = parseImportCsv(csvText);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('Keine Artikel in der CSV gefunden.');
|
||||||
|
}
|
||||||
|
if (rootCategoryId == null) {
|
||||||
|
throw new Error('Keine Root-Kategorie ausgewählt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = {
|
||||||
|
rootCategoryId,
|
||||||
|
pathToId: new Map(),
|
||||||
|
byPath: new Map(),
|
||||||
|
byName: new Map(),
|
||||||
|
brandByKey: new Map(),
|
||||||
|
};
|
||||||
|
const { pathToId, rootName } = buildCategoryPathIndex(categories, rootCategoryId);
|
||||||
|
cache.pathToId = pathToId;
|
||||||
|
cache.rootName = rootName;
|
||||||
|
|
||||||
|
const ensureCategory = createEnsureCategory(cache);
|
||||||
|
const ensureBrand = createEnsureBrand(cache);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let ok = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
onProgress?.(`${i + 1}/${rows.length}`);
|
||||||
|
const row = rows[i];
|
||||||
|
try {
|
||||||
|
const result = await importRow(row, { ensureCategory, ensureBrand });
|
||||||
|
results.push(result);
|
||||||
|
if (result.status === 'created') ok += 1;
|
||||||
|
else skipped += 1;
|
||||||
|
logImport(`ok ${row.name ?? ''} → ${result.sku ?? ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
skipped += 1;
|
||||||
|
logImport(`skip ${row.name ?? ''}: ${err.message ?? err}`);
|
||||||
|
results.push({ status: 'error', name: row.name, error: err.message ?? String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok, skipped, rows: results };
|
||||||
|
}
|
||||||
184
src/utils/importArtikelCsv.js
Normal file
184
src/utils/importArtikelCsv.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Client-side bulk import of the shop `artikeltabelle.csv` export format.
|
||||||
|
*
|
||||||
|
* The CSV is semicolon-separated (often wrapped in `="…"` from Excel export)
|
||||||
|
* with columns:
|
||||||
|
* ArtNr; Name; Preis; USt; Pfand; Hersteller; Kategorie; Barcode; V/K
|
||||||
|
*
|
||||||
|
* All work is done through the existing dashback API:
|
||||||
|
* - categories → POST /api/category
|
||||||
|
* - brands → PUT /api/brand
|
||||||
|
* - articles → POST /api/article (+ price / brand / ean / categories PUTs)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parse one `="value"` / `"value"` / plain Excel-style CSV cell. */
|
||||||
|
export function stripCell(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
let text = String(value).trim();
|
||||||
|
if (text.startsWith('=')) text = text.slice(1);
|
||||||
|
text = text.trim();
|
||||||
|
if (
|
||||||
|
text.length >= 2 &&
|
||||||
|
text.startsWith('"') &&
|
||||||
|
text.endsWith('"')
|
||||||
|
) {
|
||||||
|
text = text.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a semicolon CSV line into cells, honoring double-quoted fields.
|
||||||
|
* (Cell values are still raw; use `stripCell` for the Excel `="…"` wrapper.)
|
||||||
|
*/
|
||||||
|
export function splitCsvLine(line) {
|
||||||
|
const cells = [];
|
||||||
|
let cur = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
cur += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ';') {
|
||||||
|
cells.push(cur);
|
||||||
|
cur = '';
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cur);
|
||||||
|
return cells.map((c) => stripCell(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a CSV string into row objects keyed by the header names.
|
||||||
|
* Skips blank lines and the optional `sep=;` hint line.
|
||||||
|
*/
|
||||||
|
export function parseArtikelCsv(csvText) {
|
||||||
|
const lines = String(csvText ?? '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l !== '' && l !== 'sep=;');
|
||||||
|
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
|
||||||
|
const headers = splitCsvLine(lines[0]);
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = splitCsvLine(lines[i]);
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((header, idx) => {
|
||||||
|
row[header] = cells[idx] ?? '';
|
||||||
|
});
|
||||||
|
if (row.Name) rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a de-DE (or plain) number from a CSV field.
|
||||||
|
* Accepts `2,00`, `1.234,56`, and plain `2.00`.
|
||||||
|
*/
|
||||||
|
export function parseCsvNumber(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if (text === '') return null;
|
||||||
|
let normalized = text.replace(/\s/g, '');
|
||||||
|
if (normalized.includes(',')) {
|
||||||
|
normalized = normalized.replace(/\./g, '').replace(',', '.');
|
||||||
|
}
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EAN from a CSV cell (digits only), or null. */
|
||||||
|
export function parseCsvEan(value) {
|
||||||
|
const digits = String(value ?? '').replace(/\D/g, '');
|
||||||
|
return digits || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a `Kategorie` path (`Haupt | Grow | Erde`) into trimmed segments.
|
||||||
|
* Empty / falsy path → [].
|
||||||
|
*/
|
||||||
|
export function parseCategoryPath(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.split('|')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column index → name. Uses a forgiving match so the exact header text
|
||||||
|
* (with or without `="` wrapper, case) doesn't matter.
|
||||||
|
*/
|
||||||
|
function headerName(name) {
|
||||||
|
return stripCell(name).toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLUMNS = {
|
||||||
|
artNr: 'artnr',
|
||||||
|
name: 'name',
|
||||||
|
price: 'preis',
|
||||||
|
tax: 'ust',
|
||||||
|
deposit: 'pfand',
|
||||||
|
brand: 'hersteller',
|
||||||
|
category: 'kategorie',
|
||||||
|
ean: 'barcode',
|
||||||
|
vk: 'v/k',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one raw CSV row object into the typed import record.
|
||||||
|
* Unknown/missing columns become null/[] so downstream code can skip them.
|
||||||
|
*/
|
||||||
|
export function normalizeRow(row, headers = []) {
|
||||||
|
const byName = new Map();
|
||||||
|
(headers.length ? headers : Object.keys(row)).forEach((h) => {
|
||||||
|
byName.set(headerName(h), row[h] ?? row[stripCell(h)] ?? '');
|
||||||
|
});
|
||||||
|
const get = (key) => byName.get(COLUMNS[key]) ?? byName.get(key) ?? '';
|
||||||
|
|
||||||
|
const artNr = get('artNr');
|
||||||
|
const name = get('name');
|
||||||
|
const price = parseCsvNumber(get('price'));
|
||||||
|
const tax = parseCsvNumber(get('tax'));
|
||||||
|
const brand = get('brand');
|
||||||
|
const categoryPath = parseCategoryPath(get('category'));
|
||||||
|
const ean = parseCsvEan(get('ean'));
|
||||||
|
const vk = get('vk');
|
||||||
|
|
||||||
|
return {
|
||||||
|
artNr,
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
tax,
|
||||||
|
deposit: parseCsvNumber(get('deposit')),
|
||||||
|
brand,
|
||||||
|
categoryPath,
|
||||||
|
ean,
|
||||||
|
vk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a full CSV string into typed import records.
|
||||||
|
* @param {string} csvText
|
||||||
|
* @returns {Array<ReturnType<typeof normalizeRow>>}
|
||||||
|
*/
|
||||||
|
export function parseImportCsv(csvText) {
|
||||||
|
const rawRows = parseArtikelCsv(csvText);
|
||||||
|
const headers = rawRows.length ? Object.keys(rawRows[0]) : [];
|
||||||
|
return rawRows.map((row) => normalizeRow(row, headers));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user