From abe078ff054d420b2ea9cb69354cb3e3e395b430 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 06:37:42 +0200 Subject: [PATCH] file api --- .agents/skills/automat/SKILL.md | 183 +++++ package-lock.json | 248 ++++--- package.json | 2 +- src/api/ApiClient.js | 8 + src/api/index.js | 69 ++ src/api/storageManagerContext.js | 18 +- src/automats/deliveryEditorAutomat.js | 653 +++++++++--------- src/components/manifest/Attachment.jsx | 20 +- src/components/manifest/ManifestHeader.jsx | 9 +- src/components/manifest/ManifestPdfDialog.jsx | 12 +- src/components/manifest/TextEditorDialog.jsx | 114 ++- src/nav.js | 80 ++- src/pages/artikel/index.jsx | 13 +- src/pages/lieferung/neu.jsx | 328 ++++++--- 14 files changed, 1197 insertions(+), 560 deletions(-) create mode 100644 .agents/skills/automat/SKILL.md diff --git a/.agents/skills/automat/SKILL.md b/.agents/skills/automat/SKILL.md new file mode 100644 index 0000000..a5c55ca --- /dev/null +++ b/.agents/skills/automat/SKILL.md @@ -0,0 +1,183 @@ +--- +name: automat +description: >- + Lightweight out-of-tree state management for React PureComponent with zero hooks, HOCs, or Context Providers. + Use this skill whenever creating, modifying, or consuming Automat stores in React components, setting up + subscriptions or fine-grained selectors, configuring cascading invalidations, handling binary blobs/fetchers, or streaming via SSE. +--- + +# ⚙ react-automat + +> Zero-dependency higher-order state container for React `PureComponent`. State lives in module scope (independent of mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs. + +[![npm version](https://img.shields.io/npm/v/react-automat?style=flat-square&color=crimson)](https://www.npmjs.com/package/react-automat) +[![bundle size](https://img.shields.io/bundlephobia/minzip/react-automat?style=flat-square&color=22c55e&label=minzipped)](https://bundlephobia.com/package/react-automat) +[![Live Interactive Demo](https://img.shields.io/badge/Live%20Demo-sebgreen.net%2Fautomat-6366f1?style=flat-square)](https://sebgreen.net/automat/) +[![License: 0BSD](https://img.shields.io/badge/License-0BSD-blue.svg?style=flat-square)](LICENSE) + +```bash +npm install react-automat +# or: pnpm add react-automat | yarn add react-automat | bun add react-automat +``` + +--- + +## 1. Complete Type Signatures + +```ts +import type { PureComponent } from 'react'; + +export interface AutomatOptions { + name?: string | null; // window.__AUTOMATS__ key & Automat.get(name) + persist?: boolean; // false = window (default), true = IndexedDB + url?: string | null; // Auto-triggers fetcher(url) on init + fetcher?: (url: string, automat?: Automat) => Promise; +} + +export type Selector = keyof T | (keyof T)[] | ((state: T) => S | null | undefined); + +export interface ScopedSlice { + readonly state: S; + subscribe(target: PureComponent | ((slice: S) => void)): () => void; +} + +export interface BlobResource { + blob: Blob; url: string; size: number; type: string; loadedAt: string; + revoke: () => void; // Auto-called on setDirty() / dispose() to release memory +} + +export interface SSEOptions { + onMessage?: (data: any, event: string, automat: Automat) => void; + events?: Record) => void>; + onError?: (err: any, automat: Automat) => void; +} + +export class Automat> { + constructor(initialState?: T | null, actions?: A, options?: AutomatOptions); + + readonly state: T; + getState(): T; + setState(partial: Partial | ((prev: T) => Partial)): T; + readonly actions: A; + + readonly isReady: boolean; + readonly isDirty: boolean; + readonly ready: Promise; // Re-awaitable after setDirty() + readonly name: string | null; + readonly persist: boolean; + readonly url: string | null; + readonly subscriberCount: number; + + subscribe(target: PureComponent | ((state: T, partial?: Partial) => void), selector?: Selector): () => void; + select(selector: Selector): ScopedSlice; + unsubscribe(target: any): void; + + subscribeTo( + upstream: Automat, + transform: (upstreamState: U, currentState: T) => Partial | null | undefined, + options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) } + ): this; + + invalidateWith(upstream: Automat, filterFn?: (upstream: U, current: T) => boolean): this; + onDirty(fn: (automat: this) => void): () => void; + + setDirty(): Promise; // Resets state to null/default, revokes blobs, cascades, defers if unmounted + reload(): Promise; // Silent background re-fetch; preserves state & isReady=true + refresh(): Promise; // Alias for reload() + read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty + + clearPersistence(): Promise; + dispose(): void; // Unsubs all, revokes blobs, deletes from window registry + + static get(name: string): Automat | undefined; +} + +// Fetchers & SSE +export function fetchJson(url: string, options?: RequestInit): Promise; +export function fetchBlob(url: string, options?: RequestInit): Promise; +export const fetchImageBlob: typeof fetchBlob; +export const fetchPdfBlob: typeof fetchBlob; +export function connectSSE(automat: Automat, url: string, options?: SSEOptions): { eventSource: EventSource | null; close: () => void }; +export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise; +``` + +## 2. Canonical Usage Patterns + +### A. PureComponent Subscription +```jsx +import { PureComponent } from 'react'; +import { Automat } from 'react-automat'; + +const store = new Automat({ count: 0 }, { inc: () => store.setState({ count: store.state.count + 1 }) }); + +class Counter extends PureComponent { + constructor(props) { super(props); this.state = store.state; } // Sync read, never stale + componentDidMount() { this.unsub = store.subscribe(this); } // Auto-calls this.setState(partial) + componentWillUnmount() { this.unsub(); } + render() { return ; } +} +``` + +### B. Fine-Grained Selectors (Shallow-guarded, 0 renders if slice unchanged) +```js +store.subscribe(this, 'count'); // Single key +store.subscribe(this, ['theme', 'locale']); // Multi key +store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function +``` + +### C. Unconnected Component via Window (Zero imports of store file) +```js +const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart') +this.state = { count: store?.state?.items?.length || 0 }; +this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length })); +``` + +### D. Backend API: React Suspense vs Standard +```jsx +const user = new Automat(null, {}, { url: '/api/user' }); + +// Suspense: throws promise while unready +const Profile = () =>
{user.read().name}
; + +// Standard: check isReady +class ProfileStandard extends PureComponent { + constructor(props) { super(props); this.state = { u: user.state }; } + componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); } + componentWillUnmount() { this.unsub(); } + render() { return !user.isReady ? :
{this.state.u.name}
; } +} +``` + +### E. Cascading Invalidation, Blobs & Memory Freeing +```js +const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' }); +const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob }); +p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called + +all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted +``` + +### F. SSE Remote Trigger +```js +const live = new Automat({}, { + onSSETrigger(data, event, store) { + if (event === 'invalidate') store.setDirty(); + if (event === 'reload') store.reload(); + } +}); +connectSSE(live, '/api/stream'); +``` + +## 3. Decision Matrix: `setDirty()` vs `reload()` + +| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case | +|---|---|---|---|---|---| +| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset | +| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync | + +## 4. Invariant Rules +- Read `store.state` synchronously in `constructor(props)` — never in render bodies. +- Subscribe in `componentDidMount()` & store the returned `unsub`; unsubscribe in `componentWillUnmount()`. +- Never mutate `store.state` directly (`store.state.x = 1` ❌); use `store.setState({ x })` or actions. +- Pass selectors to `subscribe(this, selector)` on multi-key stores to skip redundant renders. +- Never wrap components in Context Providers, HOCs, or custom hooks. diff --git a/package-lock.json b/package-lock.json index a123c4a..c822103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,12 @@ "@mui/material": "^9.4.0", "@reduxjs/toolkit": "^2.12.0", "@shopify/draggable": "^1.2.1", - "automat": "file:../automat", "dashgui": "file:../dashgui", "notistack": "^3.0.2", "pdfjs-dist": "^6.3.289", "pdftotext-wasm": "file:../pdftotext-wasm", "react": "^19.3.0", + "react-automat": "^0.1.0", "react-dom": "^19.3.0", "react-redux": "^9.3.0", "react-router-dom": "^7.18.4", @@ -35,17 +35,6 @@ "vite-plugin-pwa": "^1.3.0" } }, - "../automat": { - "version": "0.1.0", - "dependencies": { - "react": "^19.3.0", - "react-dom": "^19.3.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^6.1.1", - "vite": "^8.3.0" - } - }, "../dashgui": { "version": "0.1.0", "devDependencies": { @@ -442,9 +431,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -2377,9 +2366,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { @@ -2423,9 +2412,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", "cpu": [ "arm" ], @@ -2440,9 +2429,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "cpu": [ "arm64" ], @@ -2457,9 +2446,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "cpu": [ "arm64" ], @@ -2474,9 +2463,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "cpu": [ "x64" ], @@ -2491,9 +2480,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "cpu": [ "x64" ], @@ -2508,9 +2497,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "cpu": [ "arm" ], @@ -2525,9 +2514,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "cpu": [ "arm64" ], @@ -2545,9 +2534,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "cpu": [ "arm64" ], @@ -2565,9 +2554,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "cpu": [ "ppc64" ], @@ -2585,9 +2574,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "cpu": [ "s390x" ], @@ -2605,9 +2594,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "cpu": [ "x64" ], @@ -2625,9 +2614,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "cpu": [ "x64" ], @@ -2645,9 +2634,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "cpu": [ "arm64" ], @@ -2662,9 +2651,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "cpu": [ "arm64" ], @@ -2679,9 +2668,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "cpu": [ "x64" ], @@ -3455,10 +3444,6 @@ "node": ">= 4.0.0" } }, - "node_modules/automat": { - "resolved": "../automat", - "link": true - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3543,9 +3528,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", - "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3569,9 +3554,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -3589,11 +3574,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.20", + "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -4039,9 +4024,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.428", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz", - "integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==", + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", "dev": true, "license": "ISC" }, @@ -4285,9 +4270,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -4888,12 +4873,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -5745,9 +5730,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.55", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", - "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", "dev": true, "license": "MIT", "engines": { @@ -5977,9 +5962,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6136,6 +6121,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-automat": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/react-automat/-/react-automat-0.1.0.tgz", + "integrity": "sha512-/lGKX2gXyBo52rRc6qC0ZGeThuD2C/LNjJWCChA/WC3JCJ4anyx7xCREfNmMOgRUBE0k4+LF0EbvzJwb4/RxfQ==", + "license": "0BSD", + "peerDependencies": { + "react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", @@ -6336,9 +6335,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", - "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6412,13 +6411,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.149.0", + "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6428,21 +6427,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, "node_modules/rollup": { @@ -7095,19 +7094,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", "gopd": "^1.2.0", - "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -7480,9 +7478,9 @@ "license": "ISC" }, "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4cf4899..7560ed4 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ "@mui/material": "^9.4.0", "@reduxjs/toolkit": "^2.12.0", "@shopify/draggable": "^1.2.1", - "automat": "file:../automat", "dashgui": "file:../dashgui", "notistack": "^3.0.2", "pdfjs-dist": "^6.3.289", "pdftotext-wasm": "file:../pdftotext-wasm", "react": "^19.3.0", + "react-automat": "^0.1.0", "react-dom": "^19.3.0", "react-redux": "^9.3.0", "react-router-dom": "^7.18.4", diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 7ff57da..5c3c9b2 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -105,6 +105,11 @@ 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)}`), + /** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */ + getFile: (id, fileId, { download = false } = {}) => + this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, { + query: download ? { download: 'true' } : undefined, + }), /** * POST /api/delivery/parse — multipart/form-data, field "file" * Accepts PDF or text files (max 50 MB), detection via mimetype/filename @@ -153,6 +158,9 @@ export class ApiClient { } } }, + parsePdfSse(file) { + return this.parseSse(file); + }, }; diff --git a/src/api/index.js b/src/api/index.js index 6068e7c..504a3ae 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -682,6 +682,75 @@ export const delivery = { return http(`/api/delivery/${id}`, { method: 'DELETE' }); }, + /** + * GET /api/delivery/:id/file/:fileId → Blob + * Full binary content of attached file (PDF, text, etc.) + */ + async getFile(id, fileId, { download = false } = {}) { + const query = download ? '?download=true' : ''; + const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, { + method: 'GET', + credentials: 'include', + }); + 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; + } + return res.blob(); + }, + + /** + * 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 + */ + async *parseSse(file) { + const formData = new FormData(); + formData.append('file', file, file.name); + const res = await fetch('/api/delivery/parse', { + 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(); + 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'; + } + } + } + }, }; /** diff --git a/src/api/storageManagerContext.js b/src/api/storageManagerContext.js index 7467554..ea98a5d 100644 --- a/src/api/storageManagerContext.js +++ b/src/api/storageManagerContext.js @@ -1,7 +1,16 @@ import { createContext, createElement, useContext, useRef } from 'react'; import { StorageManager } from './StorageManager'; -export const StorageManagerContext = createContext(null); +let defaultStorageManager = null; + +export function getStorageManager() { + if (!defaultStorageManager) { + defaultStorageManager = new StorageManager(); + } + return defaultStorageManager; +} + +export const StorageManagerContext = createContext(getStorageManager()); /** * Provider — mount once near the top of the tree. @@ -9,7 +18,7 @@ export const StorageManagerContext = createContext(null); export function StorageManagerProvider({ children }) { const mgrRef = useRef(null); if (!mgrRef.current) { - mgrRef.current = new StorageManager(); + mgrRef.current = getStorageManager(); } return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children); @@ -19,9 +28,6 @@ export function StorageManagerProvider({ children }) { * Hook for function components that need the store manager. */ export function useStorageManager() { - const ctx = useContext(StorageManagerContext); - if (!ctx) { - throw new Error('useStorageManager must be used within a '); - } + const ctx = useContext(StorageManagerContext) || getStorageManager(); return ctx; } diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 10c7aa2..8fe4bb0 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -1,4 +1,4 @@ -import { Automat } from 'automat'; +import { Automat } from 'react-automat'; import { supplier as supplierApi } from '../api'; export function createEmptyLineitem(line = {}) { @@ -34,7 +34,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) { map[artSku.toLowerCase()] = item; } } - // Map by EANs + // Map by Eans const eans = item.article?.eans ?? item.article?.articleean ?? []; for (const ean of eans) { if (ean != null) { @@ -77,336 +77,367 @@ export const initialState = { ...initialDelivery, }; -export const deliveryEditorAutomat = new Automat(initialState, { - setId(id) { - deliveryEditorAutomat.setState({ id, deliveryId: id }); - }, +export function createDeliveryEditorAutomat(options = {}) { + const name = typeof options === 'string' ? options : options?.name ?? null; + if (name) { + const existing = Automat.get(name); + if (existing) return existing; + } - setDeliveryEntities(deliveryEntities) { - deliveryEditorAutomat.setState({ deliveryEntities: deliveryEntities ?? [] }); - }, + let automat; + const actions = { + setId(id) { + automat.setState({ id, deliveryId: id }); + }, - setDelivery(delivery = {}) { - const rawLines = delivery.lines ?? delivery.lineitems ?? []; - const lines = Array.isArray(rawLines) ? rawLines : []; - const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null; - const extNum = delivery.externalNumber ?? delivery.external_number ?? null; - const id = delivery.id ?? delivery.deliveryId ?? null; - const notes = Array.isArray(delivery.notes) - ? delivery.notes - : delivery.notes ? [delivery.notes] : []; - const rawAttachments = delivery.attachments ?? delivery.files ?? []; - const attachments = Array.isArray(rawAttachments) ? rawAttachments : []; + setDeliveryEntities(deliveryEntities) { + automat.setState({ deliveryEntities: deliveryEntities ?? [] }); + }, - deliveryEditorAutomat.setState({ - ...initialDelivery, - ...delivery, - id, - deliveryId: id, - partyId, - deliveryEntity: partyId, - externalNumber: extNum, - external_number: extNum, - lines, - lineitems: lines, - notes, - attachments, - files: attachments, - }); + setDelivery(delivery = {}) { + const rawLines = delivery.lines ?? delivery.lineitems ?? []; + const lines = Array.isArray(rawLines) ? rawLines : []; + const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null; + const extNum = delivery.externalNumber ?? delivery.external_number ?? null; + const id = delivery.id ?? delivery.deliveryId ?? null; + const notes = Array.isArray(delivery.notes) + ? delivery.notes + : delivery.notes ? [delivery.notes] : []; + const rawAttachments = delivery.attachments ?? delivery.files ?? []; + const attachments = Array.isArray(rawAttachments) ? rawAttachments : []; - if (partyId && lines.length > 0) { - deliveryEditorAutomat.actions.fetchSupplierArticles(partyId, lines); - } - }, + automat.setState({ + ...initialDelivery, + ...delivery, + id, + deliveryId: id, + partyId, + deliveryEntity: partyId, + externalNumber: extNum, + external_number: extNum, + lines, + lineitems: lines, + notes, + attachments, + files: attachments, + }); - setDate(date) { - deliveryEditorAutomat.setState({ date }); - }, + if (partyId && lines.length > 0) { + automat.actions.fetchSupplierArticles(partyId, lines); + } + }, - setDeliveryEntity(entityId) { - const prevId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; - const idChanged = prevId !== entityId; - deliveryEditorAutomat.setState({ - deliveryEntity: entityId, - partyId: entityId, - ...(idChanged - ? { - supplierArticles: [], - supplierArticlesMap: {}, - supplierArticlesByLineKey: {}, - loadingLineKeys: {}, + setDate(date) { + automat.setState({ date }); + }, + + setDeliveryEntity(entityId) { + const prevId = automat.state.deliveryEntity ?? automat.state.partyId; + const idChanged = prevId !== entityId; + automat.setState({ + deliveryEntity: entityId, + partyId: entityId, + ...(idChanged + ? { + supplierArticles: [], + supplierArticlesMap: {}, + supplierArticlesByLineKey: {}, + loadingLineKeys: {}, + } + : {}), + }); + + if (entityId && idChanged) { + automat.actions.fetchSupplierArticles(entityId); + } + }, + + setPartyId(partyId) { + automat.actions.setDeliveryEntity(partyId); + }, + + setExternalNumber(externalNumber) { + automat.setState({ + externalNumber, + external_number: externalNumber, + }); + }, + + setNumber(number) { + automat.setState({ number }); + }, + + setType(type) { + automat.setState({ type }); + }, + + setLines(lines) { + const cleanLines = lines ?? []; + automat.setState({ lines: cleanLines, lineitems: cleanLines }); + }, + + setLineitems(lines) { + const cleanLines = lines ?? []; + automat.setState({ lines: cleanLines, lineitems: cleanLines }); + }, + + setNotes(notes) { + const cleanNotes = Array.isArray(notes) + ? notes + : notes ? [notes] : []; + automat.setState({ notes: cleanNotes }); + }, + + addLineitem(line = {}) { + const item = createEmptyLineitem(line); + const nextLines = [...(automat.state.lines ?? []), item]; + automat.setState({ lines: nextLines, lineitems: nextLines }); + }, + + addLine(line = {}) { + automat.actions.addLineitem(line); + }, + + updateLineitem(payload) { + const { index, key, ...fields } = payload || {}; + const currentLines = automat.state.lines ?? []; + let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1; + if (targetIndex === -1 && key != null) { + targetIndex = currentLines.findIndex((l) => l.key === key); + } + if (targetIndex >= 0 && targetIndex < currentLines.length) { + const nextLines = [...currentLines]; + nextLines[targetIndex] = { + ...nextLines[targetIndex], + ...fields, + }; + automat.setState({ lines: nextLines, lineitems: nextLines }); + } + }, + + updateLine(payload) { + automat.actions.updateLineitem(payload); + }, + + removeLineitem(payload) { + const currentLines = automat.state.lines ?? []; + let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1; + let targetKey = null; + if (index === -1 && payload != null) { + const key = typeof payload === 'string' ? payload : payload?.key; + if (key != null) { + index = currentLines.findIndex((l) => l.key === key); + targetKey = key; + } + } + if (index >= 0 && index < currentLines.length) { + targetKey = targetKey ?? currentLines[index]?.key; + const nextLines = currentLines.filter((_, i) => i !== index); + const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) }; + if (targetKey != null) delete byLineKey[targetKey]; + automat.setState({ + lines: nextLines, + lineitems: nextLines, + supplierArticlesByLineKey: byLineKey, + }); + } + }, + + removeLine(payload) { + automat.actions.removeLineitem(payload); + }, + + async fetchSupplierArticles(targetEntityId, targetLines) { + const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId; + if (!entityId) { + automat.setState({ + supplierArticles: [], + supplierArticlesMap: {}, + supplierArticlesByLineKey: {}, + supplierArticlesLoading: false, + loadingLineKeys: {}, + }); + return; + } + + const lines = targetLines ?? automat.state.lines ?? []; + const skus = Array.from( + new Set( + lines + .map((l) => (l.sku != null ? String(l.sku).trim() : '')) + .filter(Boolean), + ), + ); + + automat.setState({ + supplierArticlesLoading: true, + supplierArticlesError: null, + }); + + try { + const res = await supplierApi.getArticles(entityId, skus); + const articles = Array.isArray(res?.articles) ? res.articles : []; + + const map = buildSupplierArticlesMap(articles); + const byLineKey = {}; + for (const line of lines) { + if (line.key != null) { + const rawSku = line.sku != null ? String(line.sku).trim() : ''; + byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null; } - : {}), - }); - - if (entityId && idChanged) { - deliveryEditorAutomat.actions.fetchSupplierArticles(entityId); - } - }, - - setPartyId(partyId) { - deliveryEditorAutomat.actions.setDeliveryEntity(partyId); - }, - - setExternalNumber(externalNumber) { - deliveryEditorAutomat.setState({ - externalNumber, - external_number: externalNumber, - }); - }, - - setNumber(number) { - deliveryEditorAutomat.setState({ number }); - }, - - setType(type) { - deliveryEditorAutomat.setState({ type }); - }, - - setLines(lines) { - const cleanLines = lines ?? []; - deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines }); - }, - - setLineitems(lines) { - const cleanLines = lines ?? []; - deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines }); - }, - - setNotes(notes) { - const cleanNotes = Array.isArray(notes) - ? notes - : notes ? [notes] : []; - deliveryEditorAutomat.setState({ notes: cleanNotes }); - }, - - addLineitem(line = {}) { - const item = createEmptyLineitem(line); - const nextLines = [...(deliveryEditorAutomat.state.lines ?? []), item]; - deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines }); - }, - - addLine(line = {}) { - deliveryEditorAutomat.actions.addLineitem(line); - }, - - updateLineitem(payload) { - const { index, key, ...fields } = payload || {}; - const currentLines = deliveryEditorAutomat.state.lines ?? []; - let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1; - if (targetIndex === -1 && key != null) { - targetIndex = currentLines.findIndex((l) => l.key === key); - } - if (targetIndex >= 0 && targetIndex < currentLines.length) { - const nextLines = [...currentLines]; - nextLines[targetIndex] = { - ...nextLines[targetIndex], - ...fields, - }; - deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines }); - } - }, - - updateLine(payload) { - deliveryEditorAutomat.actions.updateLineitem(payload); - }, - - removeLineitem(payload) { - const currentLines = deliveryEditorAutomat.state.lines ?? []; - let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1; - let targetKey = null; - if (index === -1 && payload != null) { - const key = typeof payload === 'string' ? payload : payload?.key; - if (key != null) { - index = currentLines.findIndex((l) => l.key === key); - targetKey = key; - } - } - if (index >= 0 && index < currentLines.length) { - targetKey = targetKey ?? currentLines[index]?.key; - const nextLines = currentLines.filter((_, i) => i !== index); - const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; - if (targetKey != null) delete byLineKey[targetKey]; - deliveryEditorAutomat.setState({ - lines: nextLines, - lineitems: nextLines, - supplierArticlesByLineKey: byLineKey, - }); - } - }, - - removeLine(payload) { - deliveryEditorAutomat.actions.removeLineitem(payload); - }, - - async fetchSupplierArticles(targetEntityId, targetLines) { - const entityId = targetEntityId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; - if (!entityId) { - deliveryEditorAutomat.setState({ - supplierArticles: [], - supplierArticlesMap: {}, - supplierArticlesByLineKey: {}, - supplierArticlesLoading: false, - loadingLineKeys: {}, - }); - return; - } - - const lines = targetLines ?? deliveryEditorAutomat.state.lines ?? []; - const skus = Array.from( - new Set( - lines - .map((l) => (l.sku != null ? String(l.sku).trim() : '')) - .filter(Boolean), - ), - ); - - deliveryEditorAutomat.setState({ - supplierArticlesLoading: true, - supplierArticlesError: null, - }); - - try { - const res = await supplierApi.getArticles(entityId, skus); - const articles = Array.isArray(res?.articles) ? res.articles : []; - - const map = buildSupplierArticlesMap(articles); - const byLineKey = {}; - for (const line of lines) { - if (line.key != null) { - const rawSku = line.sku != null ? String(line.sku).trim() : ''; - byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null; } + + automat.setState({ + supplierArticles: articles, + supplierArticlesMap: map, + supplierArticlesByLineKey: byLineKey, + supplierArticlesLoading: false, + loadingLineKeys: {}, + }); + } catch (err) { + console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err); + automat.setState({ + supplierArticlesLoading: false, + supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel', + }); } + }, - deliveryEditorAutomat.setState({ - supplierArticles: articles, - supplierArticlesMap: map, - supplierArticlesByLineKey: byLineKey, - supplierArticlesLoading: false, - loadingLineKeys: {}, - }); - } catch (err) { - console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err); - deliveryEditorAutomat.setState({ - supplierArticlesLoading: false, - supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel', - }); - } - }, + async fetchSupplierArticleForSku(sku, lineKey) { + const entityId = automat.state.deliveryEntity ?? automat.state.partyId; + const trimmed = sku != null ? String(sku).trim() : ''; - async fetchSupplierArticleForSku(sku, lineKey) { - const entityId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; - const trimmed = sku != null ? String(sku).trim() : ''; - - if (!entityId || !trimmed) { - if (lineKey != null) { - const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; - byLineKey[lineKey] = null; - deliveryEditorAutomat.setState({ supplierArticlesByLineKey: byLineKey }); - } - return; - } - - const prevLoading = deliveryEditorAutomat.state.loadingLineKeys || {}; - deliveryEditorAutomat.setState({ - loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading, - }); - - try { - const res = await supplierApi.getArticles(entityId, [trimmed]); - const articles = Array.isArray(res?.articles) ? res.articles : []; - - const nextMap = buildSupplierArticlesMap(articles, deliveryEditorAutomat.state.supplierArticlesMap); - const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null; - - const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; - if (lineKey != null) { - byLineKey[lineKey] = matched; - } - - const currentList = deliveryEditorAutomat.state.supplierArticles || []; - const mergedList = [...currentList]; - for (const a of articles) { - if (!mergedList.some((existing) => existing.id === a.id)) { - mergedList.push(a); + if (!entityId || !trimmed) { + if (lineKey != null) { + const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) }; + byLineKey[lineKey] = null; + automat.setState({ supplierArticlesByLineKey: byLineKey }); } + return; } - const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) }; - if (lineKey != null) delete nextLoading[lineKey]; - - deliveryEditorAutomat.setState({ - supplierArticles: mergedList, - supplierArticlesMap: nextMap, - supplierArticlesByLineKey: byLineKey, - loadingLineKeys: nextLoading, - supplierArticlesLoading: Object.keys(nextLoading).length > 0, + const prevLoading = automat.state.loadingLineKeys || {}; + automat.setState({ + loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading, }); - } catch (err) { - console.error('Fehler beim Abrufen des Artikels für SKU:', err); - const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) }; - if (lineKey != null) delete nextLoading[lineKey]; - deliveryEditorAutomat.setState({ - loadingLineKeys: nextLoading, - supplierArticlesLoading: Object.keys(nextLoading).length > 0, + + try { + const res = await supplierApi.getArticles(entityId, [trimmed]); + const articles = Array.isArray(res?.articles) ? res.articles : []; + + const nextMap = buildSupplierArticlesMap(articles, automat.state.supplierArticlesMap); + const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null; + + const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) }; + if (lineKey != null) { + byLineKey[lineKey] = matched; + } + + const currentList = automat.state.supplierArticles || []; + const mergedList = [...currentList]; + for (const a of articles) { + if (!mergedList.some((existing) => existing.id === a.id)) { + mergedList.push(a); + } + } + + const nextLoading = { ...(automat.state.loadingLineKeys || {}) }; + if (lineKey != null) delete nextLoading[lineKey]; + + automat.setState({ + supplierArticles: mergedList, + supplierArticlesMap: nextMap, + supplierArticlesByLineKey: byLineKey, + loadingLineKeys: nextLoading, + supplierArticlesLoading: Object.keys(nextLoading).length > 0, + }); + } catch (err) { + console.error('Fehler beim Abrufen des Artikels für SKU:', err); + const nextLoading = { ...(automat.state.loadingLineKeys || {}) }; + if (lineKey != null) delete nextLoading[lineKey]; + automat.setState({ + loadingLineKeys: nextLoading, + supplierArticlesLoading: Object.keys(nextLoading).length > 0, + }); + } + }, + + addAttachment(attachment) { + const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const item = { ...attachment, id }; + const next = [...(automat.state.attachments ?? []), item]; + automat.setState({ attachments: next, files: next }); + }, + + setAttachments(attachments) { + const clean = Array.isArray(attachments) ? attachments : []; + automat.setState({ attachments: clean, files: clean }); + }, + + updateAttachment(payload) { + const { id, ...fields } = payload || {}; + const current = automat.state.attachments ?? []; + const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id)); + if (idx !== -1) { + const next = [...current]; + next[idx] = { ...next[idx], ...fields }; + automat.setState({ attachments: next, files: next }); + } + }, + + removeAttachment(idOrPayload) { + const current = automat.state.attachments ?? []; + const targetId = + typeof idOrPayload === 'object' && idOrPayload !== null + ? (idOrPayload.id ?? idOrPayload.fileId) + : idOrPayload; + const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId)); + let next; + if (hasMatch) { + next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); + } else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) { + next = current.filter((_, idx) => idx !== idOrPayload); + } else { + next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); + } + automat.setState({ attachments: next, files: next }); + }, + + resetDelivery() { + automat.setState({ + ...initialDelivery, }); - } - }, + }, - addAttachment(attachment) { - const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const item = { ...attachment, id }; - const next = [...(deliveryEditorAutomat.state.attachments ?? []), item]; - deliveryEditorAutomat.setState({ attachments: next, files: next }); - }, + resetDeliveryEditor() { + automat.setState({ + ...initialState, + }); + }, + }; - setAttachments(attachments) { - const clean = Array.isArray(attachments) ? attachments : []; - deliveryEditorAutomat.setState({ attachments: clean, files: clean }); - }, + automat = new Automat(initialState, actions, { + name, + persist: false, + }); - updateAttachment(payload) { - const { id, ...fields } = payload || {}; - const current = deliveryEditorAutomat.state.attachments ?? []; - const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id)); - if (idx !== -1) { - const next = [...current]; - next[idx] = { ...next[idx], ...fields }; - deliveryEditorAutomat.setState({ attachments: next, files: next }); - } - }, + return automat; +} - removeAttachment(idOrPayload) { - const current = deliveryEditorAutomat.state.attachments ?? []; - const targetId = - typeof idOrPayload === 'object' && idOrPayload !== null - ? (idOrPayload.id ?? idOrPayload.fileId) - : idOrPayload; - const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId)); - let next; - if (hasMatch) { - next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); - } else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) { - next = current.filter((_, idx) => idx !== idOrPayload); - } else { - next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); - } - deliveryEditorAutomat.setState({ attachments: next, files: next }); - }, +export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' }); +export const deliveryEditorAutomat = deliveryDraftAutomat; - resetDelivery() { - deliveryEditorAutomat.setState({ - ...initialDelivery, - }); - }, - - resetDeliveryEditor() { - deliveryEditorAutomat.setState({ - ...initialState, - }); - }, -}); +export function getDeliveryEditorAutomat(docId) { + if (docId == null) { + return deliveryDraftAutomat; + } + const name = `delivery-${docId}`; + let automat = Automat.get(name); + if (!automat) { + automat = createDeliveryEditorAutomat({ name }); + } + return automat; +} export const { setId, diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index e42306e..d0656db 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -31,8 +31,19 @@ export class Attachment extends PureComponent { if (onClick && attachment) { onClick(attachment); } else if (attachment?.file instanceof Blob || attachment?.file instanceof File) { - const url = URL.createObjectURL(attachment.file); - window.open(url, '_blank'); + if (attachment.file.size > 0) { + const url = URL.createObjectURL(attachment.file); + window.open(url, '_blank'); + } else { + const docId = attachment.manifestId; + const fileId = attachment.fileId ?? attachment.id; + if (docId != null && fileId != null) { + window.open( + `/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`, + '_blank', + ); + } + } } }; @@ -49,7 +60,8 @@ export class Attachment extends PureComponent { const name = attachment?.name || 'Dokument'; const size = attachment?.size; const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); - const isText = attachment?.type?.startsWith('text/') || name.toLowerCase().endsWith('.txt'); + const isCsv = attachment?.type === 'text/csv' || name.toLowerCase().endsWith('.csv'); + const isText = attachment?.type?.startsWith('text/') || name.toLowerCase().endsWith('.txt') || isCsv; return ( - TXT + {isCsv ? 'CSV' : 'TXT'} : } disabled={saving || pdfUploading} onClick={this.handleUploadClick} + title="PDF, TXT oder CSV hochladen" sx={{ whiteSpace: 'nowrap', height: 40, @@ -164,8 +165,8 @@ class ManifestHeader extends PureComponent { {pdfUploading ? 'Wird analysiert…' : isDragging - ? 'PDF hier ablegen' - : 'PDF hochladen'} + ? 'Datei hier ablegen' + : 'Datei hochladen'} )} @@ -182,7 +183,7 @@ class ManifestHeader extends PureComponent { diff --git a/src/components/manifest/ManifestPdfDialog.jsx b/src/components/manifest/ManifestPdfDialog.jsx index e70cc3a..b155b78 100644 --- a/src/components/manifest/ManifestPdfDialog.jsx +++ b/src/components/manifest/ManifestPdfDialog.jsx @@ -15,6 +15,7 @@ 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 TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; @@ -97,6 +98,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) { const { streaming = false, messages = [], result = null, error = null } = data ?? {}; const hasResult = Boolean(result); + const isTextDoc = + filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv'); + return ( ) : streaming ? ( + ) : isTextDoc ? ( + ) : ( )} - {streaming ? 'PDF wird analysiert…' : error ? 'PDF-Analyse fehlgeschlagen' : 'PDF-Analyse abgeschlossen'} + {streaming + ? (isTextDoc ? 'Dokument wird analysiert…' : 'PDF wird analysiert…') + : error + ? 'Analyse fehlgeschlagen' + : (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')} {filename ? ( diff --git a/src/components/manifest/TextEditorDialog.jsx b/src/components/manifest/TextEditorDialog.jsx index eca1a9c..f706492 100644 --- a/src/components/manifest/TextEditorDialog.jsx +++ b/src/components/manifest/TextEditorDialog.jsx @@ -1,6 +1,7 @@ import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; @@ -12,10 +13,15 @@ import Chip from '@mui/material/Chip'; import CloseIcon from '@mui/icons-material/Close'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import { enqueueSnackbar } from 'notistack'; +import { StorageManagerContext } from '../../api/storageManagerContext'; +import { delivery as deliveryApi } from '../../api'; export class TextEditorDialog extends PureComponent { + static contextType = StorageManagerContext; + constructor(props) { super(props); this.state = { @@ -26,6 +32,7 @@ export class TextEditorDialog extends PureComponent { this.handleChange = this.handleChange.bind(this); this.handleSave = this.handleSave.bind(this); this.handleCopy = this.handleCopy.bind(this); + this.handleDownload = this.handleDownload.bind(this); } componentDidMount() { @@ -34,12 +41,16 @@ export class TextEditorDialog extends PureComponent { componentDidUpdate(prevProps) { if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) { - this.loadContent(); + if (this.props.open) { + this.loadContent(); + } else { + this.setState({ content: '', initialContent: '', loading: false }); + } } } async loadContent() { - const { attachment, open } = this.props; + const { attachment, open, docId, onContentLoad } = this.props; if (!open || !attachment) return; if (typeof attachment.text === 'string') { @@ -51,7 +62,10 @@ export class TextEditorDialog extends PureComponent { return; } - if (attachment.file instanceof Blob || attachment.file instanceof File) { + if ( + (attachment.file instanceof Blob || attachment.file instanceof File) && + attachment.file.size > 0 + ) { this.setState({ loading: true }); try { const text = await attachment.file.text(); @@ -60,11 +74,44 @@ export class TextEditorDialog extends PureComponent { initialContent: text, loading: false, }); + return; } catch (err) { console.error('Fehler beim Lesen der Textdatei:', err); this.setState({ loading: false }); } } + + // Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId}) + const effectiveDocId = docId ?? attachment.manifestId; + const fileId = attachment.fileId ?? attachment.id; + if (effectiveDocId != null && fileId != null) { + this.setState({ loading: true }); + try { + const getFile = + this.context?.delivery?.getFile?.bind(this.context.delivery) || + deliveryApi.getFile; + const blob = await getFile(effectiveDocId, fileId); + const text = await blob.text(); + this.setState({ + content: text, + initialContent: text, + loading: false, + }); + onContentLoad?.(attachment, text, blob); + return; + } catch (err) { + console.error('Fehler beim Laden der Textdatei:', err); + enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' }); + this.setState({ loading: false }); + return; + } + } + + this.setState({ + content: '', + initialContent: '', + loading: false, + }); } handleChange(e) { @@ -88,6 +135,22 @@ export class TextEditorDialog extends PureComponent { } } + handleDownload() { + const { attachment } = this.props; + const { content } = this.state; + if (!content) return; + const filename = attachment?.name || 'Dokument.txt'; + const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + render() { const { open, onClose, attachment, readOnly = false } = this.props; if (!open) return null; @@ -152,8 +215,24 @@ export class TextEditorDialog extends PureComponent { display: 'flex', flexDirection: 'column', bgcolor: '#fafafa', + position: 'relative', }} > + {loading && ( + + + + )} - + + + +