This commit is contained in:
seb
2026-09-19 06:37:42 +02:00
parent 02e9a555d6
commit abe078ff05
14 changed files with 1197 additions and 560 deletions

View File

@@ -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<T> {
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<T, any>) => Promise<T>;
}
export type Selector<T, S = any> = keyof T | (keyof T)[] | ((state: T) => S | null | undefined);
export interface ScopedSlice<S> {
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<T = any, A = any> {
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
onError?: (err: any, automat: Automat<T, A>) => void;
}
export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
readonly state: T;
getState(): T;
setState(partial: Partial<T> | ((prev: T) => Partial<T>)): T;
readonly actions: A;
readonly isReady: boolean;
readonly isDirty: boolean;
readonly ready: Promise<T>; // 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<T>) => void), selector?: Selector<T>): () => void;
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
unsubscribe(target: any): void;
subscribeTo<U = any>(
upstream: Automat<U, any>,
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
): this;
invalidateWith<U = any>(upstream: Automat<U, any>, filterFn?: (upstream: U, current: T) => boolean): this;
onDirty(fn: (automat: this) => void): () => void;
setDirty(): Promise<T>; // Resets state to null/default, revokes blobs, cascades, defers if unmounted
reload(): Promise<T>; // Silent background re-fetch; preserves state & isReady=true
refresh(): Promise<T>; // Alias for reload()
read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty
clearPersistence(): Promise<void>;
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
}
// Fetchers & SSE
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
export const fetchImageBlob: typeof fetchBlob;
export const fetchPdfBlob: typeof fetchBlob;
export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?: SSEOptions<T, A>): { eventSource: EventSource | null; close: () => void };
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
```
## 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 <button onClick={store.actions.inc}>{this.state.count}</button>; }
}
```
### 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 = () => <div>{user.read().name}</div>;
// 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 ? <Spinner /> : <div>{this.state.u.name}</div>; }
}
```
### 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.

248
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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);
},
};

View File

@@ -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';
}
}
}
},
};
/**

View File

@@ -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 <StorageManagerProvider>');
}
const ctx = useContext(StorageManagerContext) || getStorageManager();
return ctx;
}

View File

@@ -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,

View File

@@ -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 (
<Box
@@ -125,7 +137,7 @@ export class Attachment extends PureComponent {
letterSpacing: '0.05em',
}}
>
TXT
{isCsv ? 'CSV' : 'TXT'}
</Typography>
</Box>
<Typography

View File

@@ -134,7 +134,7 @@ class ManifestHeader extends PureComponent {
) : null}
{onPdfUpload ? (
<FileDropzone
accept="application/pdf,.pdf"
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
disabled={saving || pdfUploading}
onFileDrop={onPdfUpload}
sx={{
@@ -150,6 +150,7 @@ class ManifestHeader extends PureComponent {
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
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'}
</Button>
)}
</FileDropzone>
@@ -182,7 +183,7 @@ class ManifestHeader extends PureComponent {
<input
ref={this.fileInputRef}
type="file"
accept="application/pdf,.pdf"
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
style={{ display: 'none' }}
onChange={this.handleFileChange}
/>

View File

@@ -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 (
<Dialog
open={open}
@@ -121,12 +125,18 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
<ErrorOutlinedIcon color="error" />
) : streaming ? (
<CircularProgress size={22} />
) : isTextDoc ? (
<TextSnippetOutlinedIcon color="action" />
) : (
<PictureAsPdfIcon color="action" />
)}
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" component="div" fontWeight={600}>
{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')}
</Typography>
{filename ? (
<Typography variant="caption" color="text.secondary">

View File

@@ -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 && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(250, 250, 250, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
@@ -216,15 +295,26 @@ export class TextEditorDialog extends PureComponent {
justifyContent: 'space-between',
}}
>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}

View File

@@ -1,3 +1,5 @@
import { PureComponent, createElement } from 'react';
import Badge from '@mui/material/Badge';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AssignmentIcon from '@mui/icons-material/Assignment';
@@ -13,15 +15,89 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { isKategorienPath } from './pages/artikel/categoryRoute';
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
class DeliveryNavIcon extends PureComponent {
constructor(props) {
super(props);
const state = deliveryEditorAutomat.state;
const isDraft = state.id == null && state.deliveryId == null;
this.state = {
hasDraft: isDraft && (state.lines?.length || 0) > 0,
};
}
componentDidMount() {
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => {
const isDraft = state.id == null && state.deliveryId == null;
return {
hasDraft: isDraft && (state.lines?.length || 0) > 0,
};
});
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { hasDraft } = this.state;
const { sx, ...rest } = this.props;
return createElement(LocalShippingIcon, {
...rest,
sx: [
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
(theme) => (hasDraft ? { color: theme.nav?.itemActiveAccent || '#2dd4bf' } : null),
],
});
}
}
export const NAV_ITEMS = [
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon },
{ path: '/lieferung', label: 'Lieferung', icon: LocalShippingIcon },
{ path: '/lieferung', label: 'Lieferung', icon: DeliveryNavIcon },
{ path: '/auftrag', label: 'Auftrag', icon: AssignmentIcon },
{ path: '/kunde', label: 'Kunde', icon: PeopleIcon },
{ path: '/infos', label: 'Infos', icon: InfoIcon },
];
class DeliveryNeuIcon extends PureComponent {
constructor(props) {
super(props);
const state = deliveryEditorAutomat.state;
const isDraft = state.id == null && state.deliveryId == null;
this.state = {
badgeCount: isDraft ? (state.lines?.length || 0) : 0,
};
}
componentDidMount() {
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => {
const isDraft = state.id == null && state.deliveryId == null;
return {
badgeCount: isDraft ? (state.lines?.length || 0) : 0,
};
});
}
componentWillUnmount() {
this.unsub?.();
}
render() {
return createElement(
Badge,
{
badgeContent: this.state.badgeCount,
invisible: !this.state.badgeCount,
color: 'primary',
max: 999,
},
createElement(AddBoxIcon, this.props)
);
}
}
export const SUB_NAV = {
'/artikel': [
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
@@ -30,7 +106,7 @@ export const SUB_NAV = {
],
'/lieferung': [
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
{ segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: AddBoxIcon },
{ segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: DeliveryNeuIcon },
{ segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon },
],
'/auftrag': [

View File

@@ -9,8 +9,7 @@ import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { formatPrice } from '../../utils/price';
import {
@@ -83,6 +82,10 @@ function initialSearchState(searchParams) {
class ArtikelSearchPage extends Component {
static contextType = StorageManagerContext;
get storageManager() {
return this.context || getStorageManager();
}
state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null };
rowMeasureRef = { current: null };
@@ -171,7 +174,7 @@ class ArtikelSearchPage extends Component {
// Kick off initial search (empty input = show all products).
const { input } = this.state;
this.context.doSearch({
this.storageManager?.doSearch({
target: input,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
@@ -220,7 +223,7 @@ class ArtikelSearchPage extends Component {
const value = event.target.value;
this.setState({ input: value });
this.context.doSearch({
this.storageManager?.doSearch({
target: value,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
@@ -240,7 +243,7 @@ class ArtikelSearchPage extends Component {
// Delegate fetch + debounce to the global StorageManager.
// The callback pushes results into local component state.
this.context.doSearch({
this.storageManager?.doSearch({
target: trimmed,
filter: filter || undefined,
offset,

View File

@@ -28,7 +28,8 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { deliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -36,9 +37,10 @@ class LieferungNeuPage extends PureComponent {
constructor(props) {
super(props);
this.cfg = manifestConfig('delivery');
this.automat = getDeliveryEditorAutomat(props.docId);
this.state = {
...deliveryEditorAutomat.state,
status: 'loadingParties',
...this.automat.state,
status: props.docId != null ? 'loading' : 'loadingParties',
error: null,
partiesLoading: true,
saving: false,
@@ -66,32 +68,38 @@ class LieferungNeuPage extends PureComponent {
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
this.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleCreateArticleForLine = this.handleCreateArticleForLine.bind(this);
}
componentDidMount() {
this.unsubscribe = deliveryEditorAutomat.subscribe(this);
this.unsubscribe = this.automat.subscribe(this);
this._cancelled = false;
if (this.props.docId != null) {
void this.loadEdit();
} else {
void this.loadPartiesOnly();
if (this.props.initialDelivery) {
deliveryEditorAutomat.actions.setDelivery(this.props.initialDelivery);
} else {
deliveryEditorAutomat.actions.resetDelivery();
this.automat.actions.setDelivery(this.props.initialDelivery);
}
}
}
componentDidUpdate(prevProps) {
if (prevProps.docId !== this.props.docId) {
this.unsubscribe?.();
this.automat = getDeliveryEditorAutomat(this.props.docId);
this.unsubscribe = this.automat.subscribe(this);
this.setState({ ...this.automat.state });
if (this.props.docId != null) {
void this.loadEdit();
} else {
void this.loadPartiesOnly();
deliveryEditorAutomat.actions.resetDelivery();
if (this.props.initialDelivery) {
this.automat.actions.setDelivery(this.props.initialDelivery);
}
}
}
}
@@ -110,7 +118,7 @@ class LieferungNeuPage extends PureComponent {
try {
const parties = await loadParties('delivery', this.context);
if (this._cancelled) return;
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
this.automat.actions.setDeliveryEntities(parties);
this.setState({
status: 'ready',
partiesLoading: false,
@@ -145,7 +153,7 @@ class LieferungNeuPage extends PureComponent {
]);
if (this._cancelled) return;
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
this.automat.actions.setDeliveryEntities(parties);
requireObject(docRes, cfg.getPathLabel);
requireObject(docRes[cfg.docField], cfg.docField);
@@ -154,17 +162,17 @@ class LieferungNeuPage extends PureComponent {
const lines =
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines);
this.automat.actions.setId(doc.id ?? docId);
this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
this.automat.actions.setLineitems(lines);
// Restore attachments with thumbnails
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
deliveryEditorAutomat.actions.setAttachments(attachments);
this.automat.actions.setAttachments(attachments);
this.setState({
status: 'ready',
@@ -186,19 +194,19 @@ class LieferungNeuPage extends PureComponent {
}
handlePartyChange(partyId) {
deliveryEditorAutomat.actions.setDeliveryEntity(partyId);
this.automat.actions.setDeliveryEntity(partyId);
}
handleDateChange(date) {
deliveryEditorAutomat.actions.setDate(date);
this.automat.actions.setDate(date);
}
handleNumberChange(number) {
deliveryEditorAutomat.actions.setNumber(number);
this.automat.actions.setNumber(number);
}
handleExternalNumberChange(externalNumber) {
deliveryEditorAutomat.actions.setExternalNumber(externalNumber);
this.automat.actions.setExternalNumber(externalNumber);
}
findLineIndex(key) {
@@ -214,7 +222,7 @@ class LieferungNeuPage extends PureComponent {
handleLineChange(key, field, value) {
const index = this.findLineIndex(key);
deliveryEditorAutomat.actions.updateLineitem({
this.automat.actions.updateLineitem({
index,
key,
[field]: value,
@@ -229,7 +237,7 @@ class LieferungNeuPage extends PureComponent {
}
this._skuDebounceTimers[key] = setTimeout(() => {
if (this._skuDebounceTimers) delete this._skuDebounceTimers[key];
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
this.automat.actions.fetchSupplierArticleForSku(value, key);
}, 350);
}
}
@@ -239,7 +247,7 @@ class LieferungNeuPage extends PureComponent {
clearTimeout(this._skuDebounceTimers[key]);
delete this._skuDebounceTimers[key];
}
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
this.automat.actions.fetchSupplierArticleForSku(value, key);
}
async handleCreateArticleForLine(line) {
@@ -294,7 +302,7 @@ class LieferungNeuPage extends PureComponent {
// Keep the supplier SKU in the delivery line, but refresh supplier article data
// using the supplier SKU (not the internal SKU)
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
enqueueSnackbar(`Artikel "${internalSku}" erstellt und Lieferanten-Artikel "${supplierSku}" zugeordnet.`, { variant: 'success' });
} catch (err) {
@@ -304,69 +312,105 @@ class LieferungNeuPage extends PureComponent {
}
handleAddLine() {
deliveryEditorAutomat.actions.addLineitem();
this.automat.actions.addLineitem();
}
handleRemoveLine(key) {
const index = this.findLineIndex(key);
deliveryEditorAutomat.actions.removeLineitem(index >= 0 ? index : key);
this.automat.actions.removeLineitem(index >= 0 ? index : key);
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
// 1. Generate thumbnail for PDF
let thumbnail = null;
try {
thumbnail = await generatePdfThumbnail(file);
} catch (err) {
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
}
const isPdf =
file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
const isCsv =
file.type === 'text/csv' || file.name?.toLowerCase().endsWith('.csv');
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const pdfAttachment = {
id: pdfId,
file,
name: file.name,
size: file.size,
type: 'application/pdf',
thumbnail,
uploadedAt: new Date().toISOString(),
};
deliveryEditorAutomat.actions.addAttachment(pdfAttachment);
if (isPdf) {
// 1. Generate thumbnail for PDF
let thumbnail = null;
try {
thumbnail = await generatePdfThumbnail(file);
} catch (err) {
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
}
// 2. Extract text using pdftotext-wasm to create 2nd file (textfile)
try {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: pdfId,
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const pdfAttachment = {
id: pdfId,
file,
name: file.name,
size: file.size,
type: 'application/pdf',
thumbnail,
uploadedAt: new Date().toISOString(),
};
deliveryEditorAutomat.actions.addAttachment(txtAttachment);
} catch (txtErr) {
console.error('Fehler beim Extrahieren von Text mit pdftotext-wasm:', txtErr);
this.automat.actions.addAttachment(pdfAttachment);
// 2. Extract text using pdftotext-wasm to create 2nd file (textfile)
try {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(),
};
this.automat.actions.addAttachment(txtAttachment);
} catch (txtErr) {
console.error('Fehler beim Extrahieren von Text mit pdftotext-wasm:', txtErr);
}
} else {
// Direct text / CSV attachment
let text = '';
try {
text = await file.text();
} catch (err) {
console.error('Fehler beim Lesen der Datei:', err);
}
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const textAttachment = {
id: attachmentId,
file,
name: file.name,
size: file.size,
type: isCsv ? 'text/csv' : 'text/plain',
text,
uploadedAt: new Date().toISOString(),
};
this.automat.actions.addAttachment(textAttachment);
}
// 3. Run SSE parsing with backend
await this.runPdfParse(file);
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
await this.runParse(file);
}
async handlePdfRescan(attachment) {
if (!attachment?.file || this.state.pdfUploading) return;
if (!attachment || this.state.pdfUploading) return;
let targetAttachment = attachment;
if (!targetAttachment.file || targetAttachment.file.size === 0) {
const file = await this.ensureAttachmentFile(targetAttachment);
if (!file || file.size === 0) {
enqueueSnackbar('Datei konnte nicht geladen werden', { variant: 'error' });
return;
}
targetAttachment = { ...targetAttachment, file };
}
// Refresh textfile upon rescan for PDF
const isPdf = attachment.type === 'application/pdf' || attachment.name.toLowerCase().endsWith('.pdf');
const isPdf = targetAttachment.type === 'application/pdf' || targetAttachment.name.toLowerCase().endsWith('.pdf');
if (isPdf) {
try {
const text = await extractPdfText(attachment.file, { layout: true });
const text = await extractPdfText(targetAttachment.file, { layout: true });
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
@@ -374,7 +418,7 @@ class LieferungNeuPage extends PureComponent {
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
if (existingTxt) {
deliveryEditorAutomat.actions.updateAttachment({
this.automat.actions.updateAttachment({
id: existingTxt.id,
file: txtFile,
size: txtFile.size,
@@ -382,7 +426,7 @@ class LieferungNeuPage extends PureComponent {
isDirty: true,
});
} else {
deliveryEditorAutomat.actions.addAttachment({
this.automat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
@@ -403,7 +447,7 @@ class LieferungNeuPage extends PureComponent {
handlePdfDelete(attachment) {
if (!attachment?.id) return;
deliveryEditorAutomat.actions.removeAttachment(attachment.id);
this.automat.actions.removeAttachment(attachment.id);
}
async runParse(file) {
@@ -500,24 +544,24 @@ class LieferungNeuPage extends PureComponent {
if (matched) supplierId = matched.id;
}
if (docDate) deliveryEditorAutomat.actions.setDate(docDate);
if (supplierId != null) deliveryEditorAutomat.actions.setDeliveryEntity(supplierId);
if (docDate) this.automat.actions.setDate(docDate);
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
if (extNum != null) {
deliveryEditorAutomat.actions.setExternalNumber(String(extNum));
this.automat.actions.setExternalNumber(String(extNum));
}
if (docType) deliveryEditorAutomat.actions.setType(docType);
if (docType) this.automat.actions.setType(docType);
if (newLines && newLines.length > 0) {
deliveryEditorAutomat.actions.setLineitems(newLines);
this.automat.actions.setLineitems(newLines);
}
if (notes.length > 0) {
deliveryEditorAutomat.actions.setNotes(notes);
this.automat.actions.setNotes(notes);
}
// Supply whole article list to GET /supplier/{id}/articles
const effectiveSupplierId = supplierId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : deliveryEditorAutomat.state.lines;
const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
deliveryEditorAutomat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
}
break;
}
@@ -567,6 +611,74 @@ class LieferungNeuPage extends PureComponent {
this.setState({ pdfDialogOpen: false });
}
async ensureAttachmentFile(attachment) {
if (!attachment) return null;
const hasContent =
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0;
if (hasContent) {
return attachment.file;
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId == null || fileId == null) {
return attachment.file || null;
}
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(docId, fileId);
const filename = attachment.name || 'Dokument';
const mimetype = attachment.type || blob.type || 'application/octet-stream';
const file = new File([blob], filename, { type: mimetype });
let text = attachment.text;
const isPdf =
mimetype === 'application/pdf' || filename.toLowerCase().endsWith('.pdf');
const isText =
!isPdf &&
(mimetype.startsWith('text/') ||
filename.toLowerCase().endsWith('.txt') ||
filename.toLowerCase().endsWith('.csv'));
if (isText && typeof text !== 'string') {
try {
text = await blob.text();
} catch (e) {
console.error('Fehler beim Dekodieren des Texts:', e);
}
}
this.automat.actions.updateAttachment({
id: attachment.id,
file,
size: blob.size,
...(typeof text === 'string' ? { text } : {}),
});
return file;
} catch (err) {
console.error('Fehler beim Laden der Dateiinhalte:', err);
notifyError(err);
return null;
}
}
handleTextEditorContentLoaded(attachment, text, blob) {
const file = new File([blob], attachment.name || 'Dokument.txt', {
type: attachment.type || 'text/plain;charset=utf-8',
});
this.automat.actions.updateAttachment({
id: attachment.id,
file,
size: blob.size,
text,
});
}
handleAttachmentClick(attachment) {
if (!attachment) return;
const isPdf =
@@ -575,19 +687,55 @@ class LieferungNeuPage extends PureComponent {
const isText =
!isPdf &&
(attachment.type?.startsWith('text/') ||
attachment.name?.toLowerCase().endsWith('.txt'));
attachment.name?.toLowerCase().endsWith('.txt') ||
attachment.name?.toLowerCase().endsWith('.csv'));
if (isText) {
this.setState({
textEditorOpen: true,
textEditorAttachment: attachment,
});
} else if (attachment.file instanceof Blob || attachment.file instanceof File) {
return;
}
if (isPdf) {
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
return;
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
void this.ensureAttachmentFile(attachment);
return;
}
}
if (attachment.file instanceof Blob || attachment.file instanceof File) {
if (attachment.file.size > 0) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
return;
}
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
}
}
handleCloseTextEditor() {
@@ -605,7 +753,7 @@ class LieferungNeuPage extends PureComponent {
type: 'text/plain;charset=utf-8',
});
deliveryEditorAutomat.actions.updateAttachment({
this.automat.actions.updateAttachment({
id: attachment.id,
file: newFile,
size: newFile.size,
@@ -657,7 +805,7 @@ class LieferungNeuPage extends PureComponent {
await this.context[cfg.updateCache]();
if (this._cancelled) return;
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
deliveryEditorAutomat.actions.resetDelivery();
this.automat.actions.resetDelivery();
this.props.navigate(cfg.editPath(data[cfg.docField].id));
} else {
const data = await this.context[cfg.api].update(docId, body);
@@ -670,17 +818,17 @@ class LieferungNeuPage extends PureComponent {
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines);
this.automat.actions.setId(doc.id ?? docId);
this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
this.automat.actions.setLineitems(lines);
// Restore attachments with thumbnails
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
deliveryEditorAutomat.actions.setAttachments(attachments);
this.automat.actions.setAttachments(attachments);
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
this.setState({ saving: false });
@@ -807,9 +955,11 @@ class LieferungNeuPage extends PureComponent {
/>
<TextEditorDialog
open={this.state.textEditorOpen}
docId={this.props.docId ?? this.state.id}
attachment={this.state.textEditorAttachment}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>
</>
);