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:
seb
2026-09-15 08:50:16 +02:00
parent 4df5110253
commit dbf978d015
9 changed files with 2064 additions and 33 deletions

View File

@@ -93,8 +93,56 @@ export class ApiClient {
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
/** DELETE /api/delivery/{id} → 204 */
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 = {
/** GET /api/quote → { quotes } */
list: () => this.#json('GET', '/api/quote'),
@@ -326,9 +374,9 @@ export class ApiClient {
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 headers = {};
const headers = { ...extraHeaders };
let payload;
if (body !== undefined) {
if (raw) {

View File

@@ -81,7 +81,7 @@ async function http(path, { method = 'GET', body } = {}) {
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;
logApi('request', {
@@ -96,7 +96,7 @@ async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
const res = await fetch(path, {
method,
headers: { 'Content-Type': contentType },
headers: { 'Content-Type': contentType, ...(extraHeaders || {}) },
credentials: 'include',
body,
});
@@ -603,6 +603,19 @@ export const delivery = {
delete(id) {
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,
});
},
};
/**