Compare commits

...

10 Commits

Author SHA1 Message Date
seb
2046f96768 supplierAutomat 2026-09-21 10:55:17 +02:00
seb
98080f0514 u 2026-09-21 07:43:15 +02:00
seb
1b6e3ec7e2 inspector 2026-09-21 04:30:11 +02:00
seb
366d2517d6 automat 2026-09-21 03:19:32 +02:00
seb
5594c59d88 upload stuff 2026-09-20 07:18:43 +02:00
seb
4011ebfc61 nav 2026-09-19 18:33:27 +02:00
seb
d3b4743b66 abort and search 2026-09-19 08:14:11 +02:00
seb
abe078ff05 file api 2026-09-19 06:37:42 +02:00
seb
02e9a555d6 do not use meta 2026-09-18 08:34:42 +02:00
seb
c0082cea65 fix 2026-09-18 08:24:35 +02:00
51 changed files with 3753 additions and 1322 deletions

View File

@@ -0,0 +1,208 @@
---
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;
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
}
// 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');
```
### G. Combine Automats
```js
const app = Automat.combine({
auth: authAutomat,
company: companyAutomat,
});
app.state.auth; // authAutomat.state
app.state.company; // companyAutomat.state
app.actions.auth.login(); // authAutomat.actions.login()
app.actions.company.rename('Acme');
// Notified when either child changes; selectors can isolate a branch.
const unsubscribe = app.subscribe(this, (state) => ({
user: state.auth.user,
companyName: state.company.name,
}));
```
The aggregate is derived: child updates automatically update the matching key. Its
`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover
all children. Calling `app.dispose()` only removes aggregate subscriptions; it does
not dispose the child automats.
## 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.

543
package-lock.json generated
View File

@@ -16,12 +16,11 @@
"@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",
"pdfconvert-wasm": "^0.3.0",
"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 +34,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": {
@@ -68,13 +56,6 @@
"react-dom": "^19.0.0"
}
},
"../pdftotext-wasm": {
"version": "0.1.0",
"license": "GPLv3",
"bin": {
"pdftotext-wasm": "bin.js"
}
},
"node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
@@ -442,9 +423,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"
@@ -2091,271 +2072,6 @@
}
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2377,9 +2093,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 +2139,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 +2156,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 +2173,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 +2190,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 +2207,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 +2224,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 +2241,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 +2261,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 +2281,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 +2301,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 +2321,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 +2341,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 +2361,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 +2378,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 +2395,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 +3171,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 +3255,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 +3281,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 +3301,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 +3751,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 +3997,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 +4600,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 +5457,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 +5689,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": {
@@ -5995,21 +5707,11 @@
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "6.3.289",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/pdftotext-wasm": {
"resolved": "../pdftotext-wasm",
"link": true
"node_modules/pdfconvert-wasm": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/pdfconvert-wasm/-/pdfconvert-wasm-0.3.0.tgz",
"integrity": "sha512-FbEk+Grg0UST4kOPuN7OFfVsut1FvDOJNPS3pt4rzCS8VlFsFzD7ptg7YtFZjEOKgJ4MgqlfCgjK4RYaRBpT0Q==",
"license": "GPLv3"
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -6136,6 +5838,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 +6052,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 +6128,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 +6144,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 +6811,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 +7195,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,11 @@
"@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",
"pdfconvert-wasm": "^0.3.0",
"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

@@ -1,9 +1,8 @@
import { Component, Suspense } from 'react';
import { connect } from 'react-redux';
import { PureComponent, Suspense } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { RouterProvider } from 'react-router-dom';
import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice';
import authAutomat from './automats/authAutomat';
import LoginPage from './components/LoginPage';
import CompanySetupDialog from './components/CompanySetupDialog';
import { router } from './appRouter';
@@ -24,9 +23,22 @@ function PageFallback() {
);
}
class App extends Component {
class App extends PureComponent {
constructor(props) {
super(props);
this.state = authAutomat.state;
}
componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
}
componentWillUnmount() {
this.unsubscribe?.();
}
render() {
const { status, needsCompanySetup } = this.props;
const { status, user } = this.state;
if (status === 'loading') {
return (
@@ -48,7 +60,7 @@ class App extends Component {
return <LoginPage />;
}
if (needsCompanySetup) {
if (user?.selectedCompanyId == null) {
return <CompanySetupDialog />;
}
@@ -60,9 +72,4 @@ class App extends Component {
}
}
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
needsCompanySetup: selectNeedsCompanySetup(state),
});
export default connect(mapStateToProps)(App);
export default App;

View File

@@ -105,19 +105,25 @@ 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
* Returns an async generator that yields SSE event objects:
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
*/
parseSse: async function* (file) {
parseSse: async function* (file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
@@ -133,25 +139,56 @@ export class ApiClient {
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete last line
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
// blank line or comment: reset
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete last line
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
// blank line or comment: reset
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
},
parsePdfSse(file, opts) {
return this.parseSse(file, opts);
},
};

View File

@@ -2,6 +2,7 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
@@ -102,6 +103,10 @@ export class StorageManager extends ApiClient {
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
this.#articlesBySku.set(key, article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key);
if (watchers) {
for (const onArticle of watchers) onArticle(article);
@@ -329,6 +334,10 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length);
// Update automat on SSE article events
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err));
}
@@ -373,7 +382,6 @@ export class StorageManager extends ApiClient {
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
@@ -415,11 +423,6 @@ export class StorageManager extends ApiClient {
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
supplierPromise: { get: () => supplierCache.promise },
updateSupplier: { value: supplierCache.update },
/** Settled GET /supplier list, or undefined. */
suppliers: { get: () => supplierCache.value },
deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */
@@ -571,12 +574,6 @@ export class StorageManager extends ApiClient {
return requireArray(data.posterminals, 'posterminals');
}
async #loadSuppliers() {
const data = await this.supplier.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
async #loadDeliveries() {
const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery');

View File

@@ -682,6 +682,104 @@ 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, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
},
};
/**

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;
}

180
src/automats/authAutomat.js Normal file
View File

@@ -0,0 +1,180 @@
import { Automat } from 'react-automat';
import { auth, company } from '../api';
/**
* @typedef {Object} AuthUser
* @property {string|number|null} [selectedCompanyId] Currently selected company.
* @property {*} [key] Additional user properties returned by the API.
*/
/**
* @typedef {Object} AuthState
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
* @property {string|null} googleClientId Google Identity Services client ID.
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
*/
/** Emitted after an authenticated session has been established. */
export const AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished';
/** Emitted after the active company has changed. */
export const AUTH_COMPANY_CHANGED = 'auth/companyChanged';
/** Emitted after the current user has logged out. */
export const AUTH_LOGGED_OUT = 'auth/loggedOut';
/** @type {AuthState} */
const initialState = {
user: null,
googleClientId: null,
status: 'loading',
};
let emit = () => { };
/**
* Registers the application-level dispatcher used for authentication events.
*
* @param {(event: {type: string}) => void} dispatch Event dispatcher.
* @returns {void}
*/
export function setAuthEventDispatcher(dispatch) {
emit = dispatch;
}
/**
* Merges API user data while retaining the current company selection when the
* response does not contain one.
*
* @param {AuthUser|null} current Current user.
* @param {AuthUser|null|undefined} incoming User data returned by the API.
* @returns {AuthUser|null} Merged user.
*/
function mergeUser(current, incoming) {
if (!incoming) return current;
const selectedCompanyId =
incoming.selectedCompanyId ?? current?.selectedCompanyId ?? null;
return { ...current, ...incoming, selectedCompanyId };
}
let authAutomat;
const actions = {
/**
* Restores an existing session or requests it from the authentication API.
*
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
* Optional preloaded session.
* @returns {Promise<Object>} Resolved session response.
*/
async bootstrapSession(session) {
try {
const nextSession = session ?? await auth.me();
const googleClientId =
nextSession.googleClientId ?? authAutomat.state.googleClientId;
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
} else {
authAutomat.setState({ googleClientId });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
authAutomat.setState({ user, googleClientId, status: 'authed' });
emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession;
} catch (error) {
if (authAutomat.state.status === 'loading') {
authAutomat.setState({ user: null, status: 'anon' });
}
throw error;
}
},
/**
* Exchanges a Google credential for an authenticated application session.
*
* @param {string} credential Google Identity Services credential.
* @returns {Promise<Object>} Authenticated session.
*/
async loginWithGoogle(credential) {
try {
const { session } = await auth.loginWithGoogle(credential);
if (!session?.user) {
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
}
authAutomat.setState({
user: session.user,
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
status: 'authed',
});
emit({ type: AUTH_SESSION_ESTABLISHED });
return session;
} catch (error) {
authAutomat.setState({ user: null, status: 'anon' });
throw error;
}
},
/**
* Selects the company used by the current session.
*
* @param {string|number} companyId Company identifier.
* @returns {Promise<AuthUser>} Updated user.
*/
async selectCompany(companyId) {
const data = await company.select(companyId);
const user = mergeUser(
authAutomat.state.user,
data.user ?? { selectedCompanyId: companyId },
);
authAutomat.setState({ user, status: 'authed' });
emit({ type: AUTH_COMPANY_CHANGED });
return user;
},
/**
* Creates a company and selects it for the current user.
*
* @param {Object} payload Company creation payload.
* @returns {Promise<AuthUser>} Updated user.
*/
async createCompany(payload) {
const data = await company.create(payload);
let incoming = data.user;
if (!incoming && data.company?.id) {
const selected = await company.select(data.company.id);
incoming = selected.user ?? { selectedCompanyId: data.company.id };
}
if (!incoming) {
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
}
const user = mergeUser(authAutomat.state.user, incoming);
authAutomat.setState({ user, status: 'authed' });
emit({ type: AUTH_COMPANY_CHANGED });
return user;
},
/**
* Ends the server session, clears the current user, and returns to `/`.
*
* @returns {Promise<void>}
*/
async logout() {
await auth.logout();
window.history.replaceState(null, '', '/');
authAutomat.setState({ user: null, status: 'anon' });
emit({ type: AUTH_LOGGED_OUT });
},
};
/**
* Global authentication state container.
*
* @type {Automat<AuthState>}
*/
authAutomat = new Automat(initialState, actions, { name: 'auth' });
export default authAutomat;

View File

@@ -1,6 +1,59 @@
import { Automat } from 'automat';
import { supplier as supplierApi } from '../api';
import { Automat } from 'react-automat';
import { article as articleApi, supplier as supplierApi } from '../api';
/**
* @typedef {Object} DeliveryLine
* @property {string|number} key Stable editor key.
* @property {string|null} name Article name.
* @property {string|number|null} sku Supplier or internal article SKU.
* @property {number|null} amount Ordered amount.
* @property {number|null} pu Price unit.
* @property {string|null} unit Unit of measure.
* @property {number|null} netprice Net price.
* @property {number|null} taxrate Tax rate.
*/
/**
* @typedef {Object} SupplierArticle
* @property {string|number} [id] Supplier-article identifier.
* @property {string|number|null} [sku] Supplier SKU.
* @property {{sku?: string|number|null, eans?: Array<string|number>, articleean?: Array<string|number>}} [article]
* Linked internal article.
*/
/**
* @typedef {Object} DeliveryEditorState
* @property {Array<Object>} deliveryEntities Available suppliers or delivery parties.
* @property {string|number|null} id Delivery identifier.
* @property {string|number|null} deliveryId Alias of `id`.
* @property {string|null} date Delivery date.
* @property {string|number|null} deliveryEntity Selected delivery party.
* @property {string|number|null} partyId Alias of `deliveryEntity`.
* @property {string|null} externalNumber External delivery number.
* @property {string|null} external_number API-compatible alias of `externalNumber`.
* @property {string|null} number Internal delivery number.
* @property {string|null} type Delivery type.
* @property {Array<DeliveryLine>} lines Editable lines.
* @property {Array<DeliveryLine>} lineitems Alias of `lines`.
* @property {Array<*>} notes Delivery notes.
* @property {Array<Object>} attachments Delivery attachments.
* @property {Array<Object>} files Alias of `attachments`.
* @property {Array<SupplierArticle>} supplierArticles Loaded supplier articles.
* @property {Object<string, SupplierArticle>} supplierArticlesMap Articles indexed by SKU or EAN.
* @property {Object<string, SupplierArticle|null>} supplierArticlesByLineKey Matched article per line.
* @property {boolean} supplierArticlesLoading Whether any supplier article request is pending.
* @property {Object<string, boolean>} loadingLineKeys Pending lookup state per line.
* @property {Object<string, boolean>} busySupplierArticleLineKeys Lines with a pending unlink request.
* @property {string|null} supplierArticlesError Last supplier article error.
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
*/
/**
* Normalizes a partial line into an editor line with a stable key.
*
* @param {Partial<DeliveryLine>} [line] Initial line values.
* @returns {DeliveryLine} Normalized line.
*/
export function createEmptyLineitem(line = {}) {
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -14,6 +67,14 @@ export function createEmptyLineitem(line = {}) {
};
}
/**
* Indexes supplier articles by supplier SKU, internal SKU, and EAN. Text keys
* are stored both verbatim and lower-cased for case-insensitive lookup.
*
* @param {Array<SupplierArticle>} [articles] Articles to index.
* @param {Object<string, SupplierArticle>} [existingMap] Existing index to extend.
* @returns {Object<string, SupplierArticle>} Article lookup map.
*/
export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
const map = { ...existingMap };
for (const item of articles) {
@@ -34,7 +95,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) {
@@ -49,6 +110,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
return map;
}
/** @type {Omit<DeliveryEditorState, 'deliveryEntities'>} */
export const initialDelivery = {
id: null,
deliveryId: null,
@@ -69,344 +131,513 @@ export const initialDelivery = {
supplierArticlesByLineKey: {},
supplierArticlesLoading: false,
loadingLineKeys: {},
busySupplierArticleLineKeys: {},
supplierArticlesError: null,
unmatchedSupplierName: null,
};
/** @type {DeliveryEditorState} */
export const initialState = {
deliveryEntities: [],
...initialDelivery,
};
export const deliveryEditorAutomat = new Automat(initialState, {
setId(id) {
deliveryEditorAutomat.setState({ id, deliveryId: id });
},
/**
* Creates a delivery editor automat or returns the already registered named
* instance. Named instances allow independent state for multiple deliveries.
*
* @param {{name?: string|null}|string} [options] Automat options or name.
* @returns {Automat<DeliveryEditorState>} Delivery editor automat.
*/
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 = {
/** @param {string|number|null} id @returns {void} */
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 : [];
/** @param {Array<Object>} deliveryEntities @returns {void} */
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,
});
/**
* Replaces the edited delivery and normalizes API aliases.
* @param {Partial<DeliveryEditorState>} [delivery]
* @returns {void}
*/
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: {},
/** @param {string|null} date @returns {void} */
setDate(date) {
automat.setState({ date });
},
/**
* Selects a delivery party and refreshes its supplier articles.
* @param {string|number|null} entityId
* @returns {void}
*/
setDeliveryEntity(entityId) {
const prevId = automat.state.deliveryEntity ?? automat.state.partyId;
const idChanged = prevId !== entityId;
automat.setState({
deliveryEntity: entityId,
partyId: entityId,
unmatchedSupplierName: entityId != null ? null : automat.state.unmatchedSupplierName,
...(idChanged
? {
supplierArticles: [],
supplierArticlesMap: {},
supplierArticlesByLineKey: {},
loadingLineKeys: {},
}
: {}),
});
if (entityId && idChanged) {
automat.actions.fetchSupplierArticles(entityId);
}
},
/** @param {string|null} name @returns {void} */
setUnmatchedSupplierName(name) {
automat.setState({ unmatchedSupplierName: name ?? null });
},
/** @param {string|number|null} partyId @returns {void} */
setPartyId(partyId) {
automat.actions.setDeliveryEntity(partyId);
},
/** @param {string|null} externalNumber @returns {void} */
setExternalNumber(externalNumber) {
automat.setState({
externalNumber,
external_number: externalNumber,
});
},
/** @param {string|null} number @returns {void} */
setNumber(number) {
automat.setState({ number });
},
/** @param {string|null} type @returns {void} */
setType(type) {
automat.setState({ type });
},
/** @param {Array<DeliveryLine>} lines @returns {void} */
setLines(lines) {
const cleanLines = lines ?? [];
automat.setState({ lines: cleanLines, lineitems: cleanLines });
},
/** @param {Array<DeliveryLine>} lines @returns {void} */
setLineitems(lines) {
const cleanLines = lines ?? [];
automat.setState({ lines: cleanLines, lineitems: cleanLines });
},
/** @param {Array<*>|*} notes @returns {void} */
setNotes(notes) {
const cleanNotes = Array.isArray(notes)
? notes
: notes ? [notes] : [];
automat.setState({ notes: cleanNotes });
},
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
addLineitem(line = {}) {
const item = createEmptyLineitem(line);
const nextLines = [...(automat.state.lines ?? []), item];
automat.setState({ lines: nextLines, lineitems: nextLines });
},
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
addLine(line = {}) {
automat.actions.addLineitem(line);
},
/**
* Updates a line selected by index or key.
* @param {{index?: number, key?: string|number, [key: string]: *}} payload
* @returns {void}
*/
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 });
}
},
/** @param {{index?: number, key?: string|number, [key: string]: *}} payload @returns {void} */
updateLine(payload) {
automat.actions.updateLineitem(payload);
},
/** @param {number|string|{key: string|number}} payload @returns {void} */
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,
});
}
},
/** @param {number|string|{key: string|number}} payload @returns {void} */
removeLine(payload) {
automat.actions.removeLineitem(payload);
},
/**
* Loads supplier articles for all current SKUs and rebuilds line matches.
* @param {string|number|null} [targetEntityId] Supplier to query.
* @param {Array<DeliveryLine>} [targetLines] Lines whose SKUs should be queried.
* @returns {Promise<void>}
*/
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;
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',
});
}
}
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,
},
/**
* Loads and matches a supplier article for one line SKU.
* @param {string|number|null} sku SKU to query.
* @param {string|number} [lineKey] Editor line key receiving the match.
* @returns {Promise<void>}
*/
async fetchSupplierArticleForSku(sku, lineKey) {
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
const trimmed = sku != null ? String(sku).trim() : '';
if (!entityId || !trimmed) {
if (lineKey != null) {
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
byLineKey[lineKey] = null;
automat.setState({ supplierArticlesByLineKey: byLineKey });
}
return;
}
const prevLoading = automat.state.loadingLineKeys || {};
automat.setState({
loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading,
});
}
},
removeLine(payload) {
deliveryEditorAutomat.actions.removeLineitem(payload);
},
try {
const res = await supplierApi.getArticles(entityId, [trimmed]);
const articles = Array.isArray(res?.articles) ? res.articles : [];
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 nextMap = buildSupplierArticlesMap(articles, automat.state.supplierArticlesMap);
const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null;
const lines = targetLines ?? deliveryEditorAutomat.state.lines ?? [];
const skus = Array.from(
new Set(
lines
.map((l) => (l.sku != null ? String(l.sku).trim() : ''))
.filter(Boolean),
),
);
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
if (lineKey != null) {
byLineKey[lineKey] = matched;
}
deliveryEditorAutomat.setState({
supplierArticlesLoading: true,
supplierArticlesError: null,
});
const currentList = automat.state.supplierArticles || [];
const mergedList = [...currentList];
for (const a of articles) {
if (!mergedList.some((existing) => existing.id === a.id)) {
mergedList.push(a);
}
}
try {
const res = await supplierApi.getArticles(entityId, skus);
const articles = Array.isArray(res?.articles) ? res.articles : [];
const nextLoading = { ...(automat.state.loadingLineKeys || {}) };
if (lineKey != null) delete nextLoading[lineKey];
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: 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,
});
}
},
/**
* Removes the supplier-article assignment behind a matched line by
* `DELETE /api/article/{sku}/supplier/{id}`, then re-resolves the line.
*
* @param {string|number} internalSku Internal article SKU.
* @param {string|number} assignmentId Article-supplier assignment ID.
* @param {string|number} [lineKey] Editor line key showing the match.
* @returns {Promise<void>}
*/
async unlinkSupplierArticle(internalSku, assignmentId, lineKey) {
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
const sku = internalSku != null ? String(internalSku).trim() : '';
if (!sku || assignmentId == null) {
throw new Error('Artikelsku und Zuordnungs-ID sind erforderlich.');
}
if (lineKey != null) {
automat.setState({
busySupplierArticleLineKeys: {
...(automat.state.busySupplierArticleLineKeys || {}),
[lineKey]: true,
},
});
}
try {
await articleApi.deleteSupplier(sku, assignmentId);
const clearedMap = { ...(automat.state.supplierArticlesMap || {}) };
for (const [mapKey, item] of Object.entries(clearedMap)) {
if (item != null && String(item.id) === String(assignmentId)) {
delete clearedMap[mapKey];
}
}
const nextList = (automat.state.supplierArticles || []).filter(
(item) => item != null && String(item.id) !== String(assignmentId),
);
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
if (lineKey != null) byLineKey[lineKey] = null;
automat.setState({
supplierArticles: nextList,
supplierArticlesMap: clearedMap,
supplierArticlesByLineKey: byLineKey,
});
if (lineKey != null && entityId) {
const line = (automat.state.lines || []).find((l) => l.key === lineKey);
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
if (rawSku) {
await automat.actions.fetchSupplierArticleForSku(rawSku, lineKey);
}
}
} finally {
if (lineKey != null) {
const nextBusy = { ...(automat.state.busySupplierArticleLineKeys || {}) };
delete nextBusy[lineKey];
automat.setState({ busySupplierArticleLineKeys: nextBusy });
}
}
},
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',
});
}
},
/** @param {Object} attachment @returns {void} */
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 });
},
async fetchSupplierArticleForSku(sku, lineKey) {
const entityId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
const trimmed = sku != null ? String(sku).trim() : '';
/** @param {Array<Object>} attachments @returns {void} */
setAttachments(attachments) {
const clean = Array.isArray(attachments) ? attachments : [];
automat.setState({ attachments: clean, files: clean });
},
if (!entityId || !trimmed) {
if (lineKey != null) {
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) };
byLineKey[lineKey] = null;
deliveryEditorAutomat.setState({ supplierArticlesByLineKey: byLineKey });
/** @param {{id: string|number, [key: string]: *}} payload @returns {void} */
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 });
}
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;
/** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */
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 });
},
const currentList = deliveryEditorAutomat.state.supplierArticles || [];
const mergedList = [...currentList];
for (const a of articles) {
if (!mergedList.some((existing) => existing.id === a.id)) {
mergedList.push(a);
}
}
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,
/** Resets delivery fields while retaining the available entities. @returns {void} */
resetDelivery() {
automat.setState({
...initialDelivery,
});
} 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,
},
/** Resets the complete editor state. @returns {void} */
resetDeliveryEditor() {
automat.setState({
...initialState,
});
}
},
},
};
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 });
},
automat = new Automat(initialState, actions, {
name,
persist: false,
});
setAttachments(attachments) {
const clean = Array.isArray(attachments) ? attachments : [];
deliveryEditorAutomat.setState({ attachments: clean, files: clean });
},
return automat;
}
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 });
}
},
/** Shared automat used for new, unsaved deliveries. */
export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
/** Backward-compatible alias of {@link deliveryDraftAutomat}. */
export const deliveryEditorAutomat = deliveryDraftAutomat;
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 });
},
resetDelivery() {
deliveryEditorAutomat.setState({
...initialDelivery,
});
},
resetDeliveryEditor() {
deliveryEditorAutomat.setState({
...initialState,
});
},
});
/**
* Returns the draft automat or the named automat belonging to a saved delivery.
*
* @param {string|number|null} docId Delivery identifier; `null` selects the draft.
* @returns {Automat<DeliveryEditorState>} Delivery-specific editor automat.
*/
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,
@@ -429,29 +660,52 @@ export const {
removeLine,
fetchSupplierArticles,
fetchSupplierArticleForSku,
unlinkSupplierArticle,
addAttachment,
setAttachments,
updateAttachment,
removeAttachment,
setUnmatchedSupplierName,
resetDelivery,
resetDeliveryEditor,
} = deliveryEditorAutomat.actions;
// Selectors
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
/** @param {DeliveryEditorState} [state] @returns {Array<DeliveryLine>} */
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
/** @param {DeliveryEditorState} [state] @returns {Array<*>} */
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
/** @param {DeliveryEditorState} [state] @returns {Array<SupplierArticle>} */
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle>} */
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle|null>} */
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
/** @param {DeliveryEditorState} [state] @returns {boolean} */
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys;
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
export const selectBusySupplierArticleLineKeys = (state = deliveryEditorAutomat.state) =>
state.busySupplierArticleLineKeys;
export default deliveryEditorAutomat;

View File

@@ -1 +1,6 @@
export * from './deliveryEditorAutomat';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as supplierAutomat } from './supplierAutomat';

View File

@@ -0,0 +1,63 @@
import { Automat } from 'react-automat';
/**
* @typedef {Object} NavigationState
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
* @property {string|null} lastArticleSku Last article SKU visited by the user.
*/
/** @type {NavigationState} */
const initialState = {
lastDeliveryPath: null,
lastArticleSku: null,
};
/**
* Stores the user's most recently visited delivery and article so main-menu
* navigation can restore the previous context.
*
* @type {Automat<NavigationState>}
*/
const navigationAutomat = new Automat(
initialState,
{
/**
* Set the last visited delivery path.
*
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void}
*/
setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path });
},
/**
* Set the last visited article SKU.
*
* @param {string|null} sku
* @returns {void}
*/
setLastArticleSku(sku) {
navigationAutomat.setState({ lastArticleSku: sku });
},
/**
* Clear all navigation history.
*
* @returns {void}
*/
clearNavigation() {
navigationAutomat.setState({
lastDeliveryPath: null,
lastArticleSku: null,
});
},
},
{
name: 'navigation',
persist: false,
}
);
export default navigationAutomat;
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions;

View File

@@ -0,0 +1,201 @@
import { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
/**
* @typedef {Object} Supplier
* @property {string|number} id Supplier identifier.
* @property {string} name Supplier name.
*/
/**
* @typedef {Object} SupplierState
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|null} error Last load error.
*/
/** @type {SupplierState} */
const initialState = {
suppliers: [],
status: 'idle',
error: null,
};
let supplierAutomat;
/**
* Fetches the company-scoped supplier list.
*
* @returns {Promise<Array<Supplier>>} Supplier list.
*/
async function fetchSuppliers() {
const data = await supplierApi.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
/** In-flight `load()` promise so concurrent consumers share one request. */
let inflightLoad = null;
/**
* Loads the supplier list once, deduplicating concurrent callers.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
function loadSuppliers() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
supplierAutomat.setState({ status: 'loading', error: null });
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
return suppliers;
} catch (error) {
supplierAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/**
* Loads the supplier list, exposing a loading/failed lifecycle.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
load() {
return loadSuppliers();
},
/**
* Silently re-fetches the supplier list while keeping the current state.
*
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
*/
async reload() {
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
return suppliers;
} catch (error) {
supplierAutomat.setState({ error });
throw error;
}
},
/**
* Creates a supplier and refreshes the cached list.
*
* @param {{name: string}} body Creation payload.
* @returns {Promise<Supplier>} Created supplier.
*/
async create(body) {
const data = await supplierApi.create(body);
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Renames a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @param {{name: string}} body Update payload.
* @returns {Promise<Supplier>} Updated supplier.
*/
async update(id, body) {
const data = await supplierApi.update(id, body);
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Deletes a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @returns {Promise<void>}
*/
async remove(id) {
await supplierApi.delete(id);
await supplierAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Supplier>} suppliers Suppliers to cache.
* @returns {void}
*/
setSuppliers(suppliers) {
supplierAutomat.setState({
suppliers: Array.isArray(suppliers) ? suppliers : [],
status: 'ready',
error: null,
});
},
/**
* Forgets the cached list and returns to the initial state.
*
* @returns {void}
*/
clear() {
supplierAutomat.setState({ ...initialState });
},
};
/**
* Company-scoped supplier cache. Loads automatically once an authenticated
* session with a selected company exists and is cleared when auth is gone.
*
* @type {Automat<SupplierState>}
*/
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
/**
* Derives the cache key from the auth state: the selected company while
* authenticated, otherwise `null`.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {string|null} Cache key.
*/
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
/**
* Loads the supplier list when auth/company becomes available and forgets it
* when the session ends.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {void}
*/
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
supplierAutomat.actions.clear();
} else {
void supplierAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
export default supplierAutomat;

View File

@@ -11,13 +11,34 @@ import UserMenu from './UserMenu';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
import { navigationAutomat } from '../automats';
const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent {
constructor(props) {
super(props);
this.state = {
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
lastArticleSku: navigationAutomat.state.lastArticleSku,
};
}
componentDidMount() {
this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastDeliveryPath: state.lastDeliveryPath,
lastArticleSku: state.lastArticleSku,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { activeNav, navigate } = this.props;
const { lastDeliveryPath, lastArticleSku } = this.state;
return (
<Box
@@ -45,7 +66,21 @@ class AppSidebar extends PureComponent {
selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
navigate(path === '/artikel' ? artikelQueryPath() : path);
if (path === '/artikel') {
if (lastArticleSku) {
navigate(`/artikel/${lastArticleSku}`);
} else {
navigate(artikelQueryPath());
}
} else if (path === '/lieferung') {
if (lastDeliveryPath) {
navigate(lastDeliveryPath);
} else {
navigate(path);
}
} else {
navigate(path);
}
}}
sx={(theme) => ({
borderRadius: 0,

View File

@@ -1,9 +1,8 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
import { StorageManagerContext } from '../api/storageManagerContext';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
import authAutomat from '../automats/authAutomat';
let sessionBootstrapped = false;
@@ -19,7 +18,7 @@ class AuthBootstrap extends Component {
if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
return this.props.bootstrapSession(session).unwrap();
return authAutomat.actions.bootstrapSession(session);
})
.catch((err) => {
notifyError(err);
@@ -31,8 +30,4 @@ class AuthBootstrap extends Component {
}
}
const mapDispatchToProps = (dispatch) => ({
bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)),
});
export default connect(null, mapDispatchToProps)(AuthBootstrap);
export default AuthBootstrap;

View File

@@ -1,5 +1,4 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -23,11 +22,7 @@ import BusinessIcon from '@mui/icons-material/Business';
import BackendError from './BackendError';
import { company } from '../api';
import { StorageManagerContext } from '../api/storageManagerContext';
import {
createCompany as createCompanyThunk,
logout as logoutThunk,
selectCompany as selectCompanyThunk,
} from '../store/authSlice';
import authAutomat from '../automats/authAutomat';
import {
defaultIso639,
ISO639_OPTIONS,
@@ -75,7 +70,7 @@ class CompanySetupDialog extends Component {
async handleSelect(companyId) {
this.setState({ busy: true, error: null });
try {
await this.props.selectCompany(companyId).unwrap();
await authAutomat.actions.selectCompany(companyId);
await this.context.updateAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
@@ -102,14 +97,12 @@ class CompanySetupDialog extends Component {
this.setState({ busy: true, error: null });
try {
await this.props
.createCompany({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
})
.unwrap();
await authAutomat.actions.createCompany({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
});
await this.context.updateAuthMe();
await this.context.updateCompany();
} catch (err) {
@@ -121,7 +114,7 @@ class CompanySetupDialog extends Component {
async handleLogout() {
try {
await this.props.logout().unwrap();
await authAutomat.actions.logout();
await this.context.updateAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
@@ -293,10 +286,4 @@ class CompanySetupDialog extends Component {
}
}
const mapDispatchToProps = (dispatch) => ({
createCompany: (payload) => dispatch(createCompanyThunk(payload)),
selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)),
logout: () => dispatch(logoutThunk()),
});
export default connect(null, mapDispatchToProps)(CompanySetupDialog);
export default CompanySetupDialog;

View File

@@ -1,11 +1,10 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack';
import { notifyErrorMessage } from '../utils/notify';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
import authAutomat from '../automats/authAutomat';
import { StorageManagerContext } from '../api/storageManagerContext';
import {
abortGoogleSignIn,
@@ -16,17 +15,25 @@ import {
requestGoogleIdToken,
} from '../auth/googleSignIn';
class LoginPage extends Component {
class LoginPage extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = { busy: false };
this.state = { ...authAutomat.state, busy: false };
this.handleLogin = this.handleLogin.bind(this);
}
componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'googleClientId']);
}
componentWillUnmount() {
this.unsubscribe?.();
}
async handleLogin() {
const { googleClientId } = this.props;
const { googleClientId } = this.state;
abortGoogleSignIn();
try {
const idToken = await requestGoogleIdToken(googleClientId, {
@@ -38,7 +45,7 @@ class LoginPage extends Component {
},
});
this.setState({ busy: true });
await this.props.loginWithGoogle(idToken).unwrap();
await authAutomat.actions.loginWithGoogle(idToken);
await this.context.updateAuthMe();
} catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
@@ -74,8 +81,7 @@ class LoginPage extends Component {
}
render() {
const { busy } = this.state;
const { status, googleClientId } = this.props;
const { busy, status, googleClientId } = this.state;
const loading = status === 'loading';
const ready = Boolean(googleClientId);
@@ -103,13 +109,4 @@ class LoginPage extends Component {
}
}
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
googleClientId: selectGoogleClientId(state),
});
const mapDispatchToProps = (dispatch) => ({
loginWithGoogle: (credential) => dispatch(loginWithGoogle(credential)),
});
export default connect(mapStateToProps, mapDispatchToProps)(LoginPage);
export default LoginPage;

View File

@@ -1,5 +1,4 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { PureComponent } from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
@@ -11,12 +10,12 @@ import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business';
import SettingsIcon from '@mui/icons-material/Settings';
import { StorageManagerContext } from '../api/storageManagerContext';
import { logout } from '../store/authSlice';
import authAutomat from '../automats/authAutomat';
import CompanySettingsDialog from './CompanySettingsDialog';
import SettingsDialog from './settings/SettingsDialog';
import { notifyError } from '../utils/notify';
class UserMenu extends Component {
class UserMenu extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
@@ -25,7 +24,7 @@ class UserMenu extends Component {
anchorEl: null,
settingsOpen: false,
numbersOpen: false,
user: null,
user: authAutomat.state.user,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
@@ -37,16 +36,11 @@ class UserMenu extends Component {
}
componentDidMount() {
void this.loadUser();
this.unsubscribe = authAutomat.subscribe(this, 'user');
}
async loadUser() {
try {
const session = await this.context.authMePromise;
this.setState({ user: session.user });
} catch (err) {
notifyError(err);
}
componentWillUnmount() {
this.unsubscribe?.();
}
handleOpenMenu(event) {
@@ -76,7 +70,7 @@ class UserMenu extends Component {
async handleLogout() {
this.setState({ anchorEl: null });
try {
await this.props.logout().unwrap();
await authAutomat.actions.logout();
await this.context.updateAuthMe();
} catch (err) {
notifyError(err);
@@ -134,8 +128,4 @@ class UserMenu extends Component {
}
}
const mapDispatchToProps = (dispatch) => ({
logout: () => dispatch(logout()),
});
export default connect(null, mapDispatchToProps)(UserMenu);
export default UserMenu;

View File

@@ -6,6 +6,7 @@ import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile';
@@ -24,34 +25,52 @@ class ArticleView extends PureComponent {
constructor(props, context) {
super(props, context);
const { article, unwatch } = context.article.watch(
props.sku,
(next) => {
this.setState({ article: next });
},
);
this.state = { article };
this.unwatchArticle = unwatch;
const sku = props.sku;
const currentArticle = articleAutomat.state[sku] ?? null;
this.state = { article: currentArticle };
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
}
componentDidUpdate(prevProps) {
const { sku } = this.props;
if (prevProps.sku !== sku) {
this.unwatchArticle?.();
const { article: next, unwatch } = this.context.article.watch(
sku,
(updated) => this.setState({ article: updated }),
);
this.unwatchArticle = unwatch;
this.setState({ article: next });
if (!currentArticle) {
articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
}
componentDidMount() {
this.subscribeToArticle();
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
const currentArticle = articleAutomat.state[this.props.sku] ?? null;
this.setState({ article: currentArticle });
if (!currentArticle) {
articleAutomat.actions.loadArticle(this.props.sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.subscribeToArticle();
}
}
subscribeToArticle() {
if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({
article: selector(state),
}));
}
componentWillUnmount() {
this.unwatchArticle?.();
this.unwatchArticle = null;
if (this.unsub) {
this.unsub();
this.unsub = null;
}
}
handleArticleDeleted() {

View File

@@ -23,6 +23,7 @@ import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { notifyError } from '../../../utils/notify';
import { formatPrice } from '../../../utils/price';
import supplierAutomat from '../../../automats/supplierAutomat';
import ArticleSupplierDialog from '../ArticleSupplierDialog';
export default class ArticleSupplierTab extends PureComponent {
@@ -80,7 +81,7 @@ export default class ArticleSupplierTab extends PureComponent {
// 2. Fetch available company suppliers and company metadata
try {
const [companySuppliers, company] = await Promise.all([
this.context.suppliers ?? this.context.supplierPromise,
supplierAutomat.actions.load(),
this.context.company ?? this.context.companyPromise,
]);
this.setState({

View File

@@ -0,0 +1,344 @@
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';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Alert from '@mui/material/Alert';
import CloseIcon from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import LinkIcon from '@mui/icons-material/Link';
import { StorageManagerContext } from '../../api/storageManagerContext';
import ArticlePhoto from '../article/ArticlePhoto';
export default class ArticleSearchDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
query: '',
articles: [],
loading: false,
error: null,
assigningSku: null,
};
this.handleQueryChange = this.handleQueryChange.bind(this);
this.handleClearQuery = this.handleClearQuery.bind(this);
this.handleSelect = this.handleSelect.bind(this);
}
componentDidMount() {
if (this.props.open) {
this.initFromLine();
void this.loadArticles();
}
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.initFromLine();
void this.loadArticles();
}
}
initFromLine() {
const { line } = this.props;
const initialQuery = line?.name?.trim() || line?.sku?.trim() || '';
this.setState({
query: initialQuery,
assigningSku: null,
error: null,
});
}
async loadArticles() {
this.setState({ loading: true, error: null });
try {
let list = [];
if (this.context?.articleIndexPromise) {
list = await this.context.articleIndexPromise;
} else if (this.context?.article?.index) {
const res = await this.context.article.index();
list = res?.articles || [];
}
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
} catch (err) {
console.error('Fehler beim Laden des Artikel-Index:', err);
this.setState({
loading: false,
error: err?.message || 'Artikel-Index konnte nicht geladen werden.',
});
}
}
handleQueryChange(e) {
this.setState({ query: e.target.value });
}
handleClearQuery() {
this.setState({ query: '' });
}
async handleSelect(article) {
const { onAssign, line } = this.props;
if (!onAssign || !article?.sku) return;
this.setState({ assigningSku: article.sku, error: null });
try {
await onAssign(article, line);
} catch (err) {
this.setState({
assigningSku: null,
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
});
}
}
render() {
const { open, onClose, line } = this.props;
const { query, articles, loading, error, assigningSku } = this.state;
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? articles.filter((a) => {
const skuMatch = a.sku?.toLowerCase().includes(trimmed);
const nameMatch = a.name?.toLowerCase().includes(trimmed);
const eanMatch = Array.isArray(a.eans)
? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
: false;
return skuMatch || nameMatch || eanMatch;
})
: articles.slice(0, 50);
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
return (
<Dialog
open={open}
onClose={assigningSku ? undefined : onClose}
maxWidth="sm"
fullWidth
aria-labelledby="article-search-dialog-title"
>
<DialogTitle
id="article-search-dialog-title"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
}}
>
<Box>
<Typography variant="h6" component="div" fontWeight={600}>
Existierenden Artikel zuordnen
</Typography>
<Typography variant="caption" color="text.secondary">
Lieferanten-SKU: <strong>{rawSku || '–'}</strong>
{line?.name ? ` · ${line.name}` : ''}
</Typography>
</Box>
<IconButton
size="small"
aria-label="Schließen"
disabled={Boolean(assigningSku)}
onClick={onClose}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
fullWidth
size="small"
placeholder="Artikel nach SKU, Name oder EAN suchen…"
value={query}
onChange={this.handleQueryChange}
disabled={Boolean(assigningSku)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
endAdornment: query ? (
<InputAdornment position="end">
<IconButton size="small" onClick={this.handleClearQuery} edge="end">
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
sx={{ mb: 1.5 }}
/>
{loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
Artikel werden geladen…
</Typography>
</Box>
) : filtered.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 5 }}>
<Typography variant="body2" color="text.secondary">
Keine Artikel für &bdquo;{query}&ldquo; gefunden.
</Typography>
</Box>
) : (
<List
dense
disablePadding
sx={{
maxHeight: 380,
overflowY: 'auto',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
>
{filtered.map((article) => {
const photo = article.photos?.[0] || article.articlepic?.[0];
const fullHash = photo?.hash || photo?.fullHash;
const thumbHash = photo?.thumbhash || photo?.thumbHash;
const thumbW = photo?.thumbw || photo?.thumbW;
const thumbH = photo?.thumbh || photo?.thumbH;
const hasPhoto = fullHash != null || thumbHash != null;
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
const isAssigning = assigningSku === article.sku;
return (
<ListItemButton
key={article.id || article.sku}
divider
disabled={Boolean(assigningSku)}
onClick={() => this.handleSelect(article)}
sx={{
py: 1,
px: 1.5,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ListItemAvatar sx={{ minWidth: 44 }}>
<Box
sx={{
width: 34,
height: 34,
borderRadius: 0.75,
overflow: 'hidden',
bgcolor: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={34}
fill
objectFit="cover"
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
)}
</Box>
</ListItemAvatar>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
size="small"
label={article.sku}
sx={{
height: 18,
fontSize: '0.68rem',
fontWeight: 600,
bgcolor: 'primary.50',
color: 'primary.dark',
}}
/>
<Typography variant="body2" fontWeight={600} noWrap sx={{ maxWidth: 300 }}>
{article.name || '–'}
</Typography>
</Box>
}
secondary={
primaryEan ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
>
EAN: {primaryEan}
</Typography>
) : null
}
/>
<Button
size="small"
variant="outlined"
startIcon={isAssigning ? <CircularProgress size={12} /> : <LinkIcon sx={{ fontSize: 14 }} />}
disabled={Boolean(assigningSku)}
onClick={(e) => {
e.stopPropagation();
void this.handleSelect(article);
}}
sx={{
fontSize: '0.72rem',
textTransform: 'none',
py: 0.25,
px: 1.25,
ml: 1,
flexShrink: 0,
}}
>
{isAssigning ? 'Zuordnen…' : 'Zuordnen'}
</Button>
</ListItemButton>
);
})}
</List>
)}
</DialogContent>
<DialogActions sx={{ px: 2, py: 1.5 }}>
<Button
size="small"
disabled={Boolean(assigningSku)}
onClick={onClose}
>
Abbrechen
</Button>
</DialogActions>
</Dialog>
);
}
}

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

@@ -59,6 +59,7 @@ class ManifestForm extends PureComponent {
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
}
componentDidMount() {
@@ -78,6 +79,10 @@ class ManifestForm extends PureComponent {
componentWillUnmount() {
this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
}
async loadPartiesOnly() {
@@ -210,15 +215,21 @@ class ManifestForm extends PureComponent {
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
});
const appendMessage = (msg) => {
if (this._cancelled) return;
if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
@@ -231,8 +242,8 @@ class ManifestForm extends PureComponent {
const gen = this.context?.delivery?.parsePdfSse;
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) {
if (this._cancelled) return;
for await (const { event, data } of this.context.delivery.parsePdfSse(file, { signal: abortController.signal })) {
if (this._cancelled || abortController.signal.aborted) return;
switch (event) {
case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) });
@@ -267,6 +278,17 @@ class ManifestForm extends PureComponent {
}
} catch (err) {
if (this._cancelled) return;
if (abortController.signal.aborted || err.name === 'AbortError') {
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
},
}));
return;
}
notifyError(err);
this.setState((prev) => ({
pdfDialogData: {
@@ -276,6 +298,9 @@ class ManifestForm extends PureComponent {
},
}));
} finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) {
this.setState((prev) => ({
pdfUploading: false,
@@ -287,9 +312,30 @@ class ManifestForm extends PureComponent {
}
}
handleAbortParse() {
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState((prev) => ({
pdfUploading: false,
pdfDialogData: prev.pdfDialogData
? {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
}
: null,
}));
}
handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false });
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: false });
}
async handleSave() {
@@ -454,6 +500,7 @@ class ManifestForm extends PureComponent {
filename={pdfFileName}
data={pdfDialogData}
onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/>
</>
);

View File

@@ -1,4 +1,5 @@
import { Fragment, PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
@@ -7,15 +8,19 @@ import IconButton from '@mui/material/IconButton';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
import { LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice } from '../../utils/price';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
class ManifestFormLine extends PureComponent {
constructor(props) {
@@ -23,6 +28,8 @@ class ManifestFormLine extends PureComponent {
this.handleRemove = this.handleRemove.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
}
handleRemove() {
@@ -42,6 +49,20 @@ class ManifestFormLine extends PureComponent {
}
}
handleSearchArticle() {
const { line, onSearchArticle } = this.props;
if (onSearchArticle) {
onSearchArticle(line);
}
}
handleRemoveSupplierArticle() {
const { line, supplierArticle, onRemoveSupplierArticle } = this.props;
if (onRemoveSupplierArticle) {
onRemoveSupplierArticle(line, supplierArticle);
}
}
renderField(column) {
const { id, align, type } = column;
const { line, saving, onChange } = this.props;
@@ -151,6 +172,9 @@ class ManifestFormLine extends PureComponent {
{/* Internal Article SKU */}
{article.sku && (
<Chip
component={RouterLink}
to={artikelDetailPath(article)}
clickable
size="small"
label={`Art.-Nr.: ${article.sku}`}
sx={{
@@ -161,6 +185,11 @@ class ManifestFormLine extends PureComponent {
color: 'primary.dark',
border: '1px solid',
borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}}
/>
)}
@@ -196,24 +225,6 @@ class ManifestFormLine extends PureComponent {
</Typography>
)}
{/* Stock */}
<Chip
size="small"
variant="outlined"
label={`Bestand: ${supplierArticle.stock ?? 0}`}
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
/>
{/* Packaging Unit (PU) */}
{supplierArticle.pu != null && (
<Chip
size="small"
variant="outlined"
label={`VPE: ${supplierArticle.pu}`}
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
/>
)}
{/* Stored Purchase Price (Netto) */}
{priceNet != null && (
<Typography
@@ -225,6 +236,21 @@ class ManifestFormLine extends PureComponent {
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
</Typography>
)}
{/* Remove supplier-article connection */}
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
<span style={{ marginLeft: priceNet != null ? 0 : 'auto' }}>
<IconButton
size="small"
color="error"
aria-label="Lieferanten-Verknüpfung entfernen"
disabled={this.props.saving || this.props.supplierArticleBusy}
onClick={this.handleRemoveSupplierArticle}
>
<LinkOffOutlinedIcon fontSize="small" />
</IconButton>
</span>
)}
</Box>
);
}
@@ -258,6 +284,28 @@ class ManifestFormLine extends PureComponent {
Artikel anlegen
</Button>
)}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
)}
</Box>
);
}
@@ -298,13 +346,13 @@ class ManifestFormLine extends PureComponent {
</TableRow>
{/* Row 2: Matched Supplier Article Details */}
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}>
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
<TableCell
colSpan={columns.length + 1}
sx={{
py: 0.4,
px: 2,
bgcolor: 'rgba(248, 250, 252, 0.75)',
bgcolor: 'transparent',
}}
>
{this.renderSecondLine()}

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

@@ -30,6 +30,9 @@ class ManifestLines extends PureComponent {
loadingLineKeys = {},
onSkuBlur,
onCreateArticle,
onSearchArticle,
onRemoveSupplierArticle,
busySupplierArticleLineKeys = {},
} = this.props;
return (
@@ -38,7 +41,7 @@ class ManifestLines extends PureComponent {
<TableContainer>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableRow sx={{ '& th': { borderBottom: 'none', fontWeight: 700 } }}>
{columns.map((col) => (
<TableCell
key={col.id}
@@ -47,12 +50,13 @@ class ManifestLines extends PureComponent {
width: col.width,
whiteSpace: 'nowrap',
bgcolor: linesColor,
fontWeight: 700,
}}
>
{col.label}
</TableCell>
))}
<TableCell padding="checkbox" sx={{ bgcolor: linesColor }} />
<TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
</TableRow>
</TableHead>
<TableBody>
@@ -74,10 +78,13 @@ class ManifestLines extends PureComponent {
saving={saving}
supplierArticle={matched}
loading={Boolean(isLineLoading)}
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
onChange={onLineChange}
onRemove={onRemoveLine}
onSkuBlur={onSkuBlur}
onCreateArticle={onCreateArticle}
onSearchArticle={onSearchArticle}
onRemoveSupplierArticle={onRemoveSupplierArticle}
/>
);
})}

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';
@@ -91,16 +92,30 @@ function EventLog({ messages, streaming }) {
);
}
export default function ManifestPdfDialog({ open, filename, data, onClose }) {
export default function ManifestPdfDialog({
open,
filename,
data,
unmatchedSupplierName,
onCreateSupplier,
onClose,
onAbort,
}) {
if (!open) return null;
const { streaming = false, messages = [], result = null, error = null } = data ?? {};
const { streaming = false, messages = [], result = null, error = null, aborted = false } = data ?? {};
const hasResult = Boolean(result);
const isTextDoc =
filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv');
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
return (
<Dialog
open={open}
onClose={streaming ? undefined : onClose}
onClose={handleAbortOrClose}
maxWidth="sm"
fullWidth
aria-labelledby="pdf-dialog-title"
@@ -117,16 +132,26 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
borderColor: 'divider',
}}
>
{error ? (
{aborted ? (
<ErrorOutlinedIcon color="warning" />
) : error ? (
<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…')
: aborted
? 'Analyse abgebrochen'
: error
? 'Analyse fehlgeschlagen'
: (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')}
</Typography>
{filename ? (
<Typography variant="caption" color="text.secondary">
@@ -136,9 +161,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</Box>
<IconButton
aria-label="close"
onClick={onClose}
onClick={handleAbortOrClose}
size="small"
disabled={streaming}
sx={{ color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon fontSize="small" />
@@ -146,9 +170,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</DialogTitle>
<DialogContent dividers sx={{ p: 2 }}>
{/* Error */}
{/* Error or Aborted Alert */}
{error && (
<Alert severity="error" icon={<ErrorOutlinedIcon />} sx={{ mb: 2 }}>
<Alert severity={aborted ? 'warning' : 'error'} icon={aborted ? undefined : <ErrorOutlinedIcon />} sx={{ mb: 2 }}>
<Typography variant="body2" fontWeight={600}>{error}</Typography>
</Alert>
)}
@@ -157,18 +181,69 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
<EventLog messages={messages} streaming={streaming} />
{/* Success notification */}
{hasResult && !streaming && !error && (
{hasResult && !streaming && !error && !aborted && (
<Alert severity="success" sx={{ mt: 2 }}>
Daten erfolgreich in das Formular übernommen.
</Alert>
)}
{/* Unmatched supplier alert */}
{hasResult && !streaming && !error && !aborted && unmatchedSupplierName && (
<Alert
severity="warning"
sx={{ mt: 1.5, alignItems: 'center' }}
action={
onCreateSupplier ? (
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => onCreateSupplier(unmatchedSupplierName)}
>
Lieferant anlegen
</Button>
) : null
}
>
Lieferant „{unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Alert>
)}
</DialogContent>
<DialogActions sx={{ p: 1.5, px: 2 }}>
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}>
Schließen
</Button>
{streaming ? (
<Button
onClick={onAbort || onClose}
variant="outlined"
color="error"
size="small"
startIcon={<CloseIcon />}
>
Abbrechen
</Button>
) : (
<>
{unmatchedSupplierName && onCreateSupplier ? (
<Button
onClick={() => onCreateSupplier(unmatchedSupplierName)}
variant="contained"
color="primary"
size="small"
>
Lieferant „{unmatchedSupplierName}“ anlegen
</Button>
) : null}
<Button
onClick={onClose}
variant={unmatchedSupplierName && onCreateSupplier ? 'outlined' : 'contained'}
size="small"
>
Schließen
</Button>
</>
)}
</DialogActions>
</Dialog>
);
}

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

@@ -6,6 +6,7 @@ export { default as ManifestFormLine } from './ManifestFormLine';
export { default as Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog';
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId';
export {
manifestConfig,

View File

@@ -1,3 +1,5 @@
import supplierAutomat from '../../automats/supplierAutomat';
/** Groups that are not sales customers (filter client-side). */
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
@@ -173,20 +175,6 @@ export async function buildFilesForUpload(attachments = [], { onlyNew = false }
filePayload.thumbavif = thumbBase64;
}
const meta = {};
if (attachment.thumbnail) {
meta.thumbnail = attachment.thumbnail;
}
if (attachment.text) {
meta.text = attachment.text;
}
if (attachment.parentPdfId) {
meta.parentPdfId = String(attachment.parentPdfId);
}
if (Object.keys(meta).length > 0) {
filePayload.meta = meta;
}
files.push(filePayload);
} catch (err) {
console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
@@ -268,12 +256,9 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
if (apiFile.thumbavif) {
try {
if (typeof apiFile.thumbavif === 'string') {
if (apiFile.thumbavif.startsWith('data:')) {
thumbnail = apiFile.thumbavif;
} else {
const thumbBlob = base64ToBlob(apiFile.thumbavif, 'image/avif');
thumbnail = URL.createObjectURL(thumbBlob);
}
thumbnail = apiFile.thumbavif.startsWith('data:')
? apiFile.thumbavif
: `data:image/avif;base64,${apiFile.thumbavif}`;
} else if (typeof apiFile.thumbavif === 'object') {
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) {
@@ -286,24 +271,7 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
}
}
// 2. Check meta.thumbnail if thumbavif wasn't available
if (!thumbnail && apiFile.meta?.thumbnail) {
try {
const metaThumb = apiFile.meta.thumbnail;
if (typeof metaThumb === 'string') {
if (metaThumb.startsWith('data:') || metaThumb.startsWith('blob:')) {
thumbnail = metaThumb;
} else {
const thumbBlob = base64ToBlob(metaThumb, 'image/avif');
thumbnail = URL.createObjectURL(thumbBlob);
}
}
} catch (metaThumbErr) {
console.error('Fehler beim Dekodieren von meta.thumbnail:', metaThumbErr);
}
}
// 3. Fallback to existing matching attachment thumbnail
// 2. Fallback to existing matching attachment thumbnail
const matchingExisting =
existingMap.get(String(apiFile.id)) ||
existingMap.get(String(apiFile.fileId)) ||
@@ -320,8 +288,8 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
? matchingExisting.file
: new File([''], placeholderName, { type: placeholderType });
const text = apiFile.meta?.text || matchingExisting?.text || null;
const parentPdfId = apiFile.meta?.parentPdfId || matchingExisting?.parentPdfId || null;
const text = matchingExisting?.text || null;
const parentPdfId = matchingExisting?.parentPdfId || null;
attachments.push({
id: apiFile.id,
@@ -373,19 +341,10 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
}
}
const meta = {};
if (attachment.parentPdfId) {
meta.parentPdfId = String(attachment.parentPdfId);
}
if (attachment.text) {
meta.text = String(attachment.text);
}
files.push({
file: fileToUpload,
name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type,
meta: Object.keys(meta).length > 0 ? meta : undefined,
});
}
return files;
@@ -429,14 +388,6 @@ export async function buildManifestBody(
const isUpdate = !requireAtLeastOne;
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) {
const filesMeta = filesToUpload
.filter((f) => f.meta)
.map((f) => ({ name: f.name, meta: f.meta }));
if (filesMeta.length > 0) {
body.filesMeta = filesMeta;
}
const formData = new FormData();
formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) {
@@ -475,7 +426,10 @@ export const DELIVERY_LINE_COLUMNS = [
*/
export async function loadParties(kind, ctx) {
if (kind === 'delivery') {
const list = ctx.suppliers ?? (await ctx.supplierPromise);
if (supplierAutomat.state.status === 'ready') {
return sortSuppliers(supplierAutomat.state.suppliers);
}
const list = await supplierAutomat.actions.load();
return sortSuppliers(list);
}
const [entities, groups] = await Promise.all([

View File

@@ -1,9 +1,11 @@
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
export default function withNavigate(Wrapped) {
function NavigateWrapper(props) {
const navigate = useNavigate();
return <Wrapped {...props} navigate={navigate} />;
const location = useLocation();
return <Wrapped {...props} navigate={navigate} location={location} />;
}
return NavigateWrapper;
}

View File

@@ -0,0 +1,537 @@
import { PureComponent } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import BugReportIcon from '@mui/icons-material/BugReport';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import CloseIcon from '@mui/icons-material/Close';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RefreshIcon from '@mui/icons-material/Refresh';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
/**
* Dev-only overlay that lists every Automat registered in the
* `window.__AUTOMATS__` registry (react-automat writes named instances there).
*
* Loaded exclusively through a `import.meta.env.DEV` guarded dynamic import in
* `main.jsx`, so the module never reaches the production bundle.
*/
/** Registry polling interval in ms; lazy automats (`delivery-<id>`) appear over time. */
const REGISTRY_POLL_MS = 2000;
/** Serialization limits so huge states stay renderable. */
const MAX_DEPTH = 6;
const MAX_STRING = 1500;
const MAX_ITEMS = 50;
/**
* Live registry view. Named automats register themselves in the constructor,
* so the map may be absent until the first one is created.
*
* @returns {Map<string, import('react-automat').Automat>} Registered automats.
*/
function getRegistry() {
const registry = window.__AUTOMATS__;
return registry instanceof Map ? registry : new Map();
}
/**
* Quotes a string and truncates oversized values.
*
* @param {string} text Value to quote.
* @returns {string} Quoted, possibly truncated string.
*/
function quote(text) {
if (text.length <= MAX_STRING) return JSON.stringify(text);
const rest = text.length - MAX_STRING;
return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
}
/**
* Renders `entries` as an indented block.
*
* @param {string} open Opening bracket.
* @param {string} close Closing bracket.
* @param {Array<string>} entries Already serialized entries.
* @param {string} pad Indentation of the enclosing level.
* @returns {string} Indented block.
*/
function block(open, close, entries, pad) {
if (entries.length === 0) return `${open}${close}`;
const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
return `${open}\n${body}\n${pad}${close}`;
}
/**
* Serializes an arbitrary state value for display. Handles functions, blobs,
* DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
* would drop or throw on.
*
* @param {*} value Value to serialize.
* @param {number} depth Current nesting depth.
* @param {WeakSet<object>} ancestors Objects on the current path, for cycle detection.
* @param {string} pad Indentation of the current level.
* @returns {string} Human-readable representation.
*/
function serialize(value, depth, ancestors, pad) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
const type = typeof value;
if (type === 'string') return quote(value);
if (type === 'number' || type === 'boolean') return String(value);
if (type === 'bigint') return `${value}n`;
if (type === 'function') return `[Function ${value.name || 'anonym'}]`;
if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`;
if (type !== 'object') return String(value);
if (value instanceof Date) return `[Date ${value.toISOString()}]`;
if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`;
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`;
if (value instanceof Promise) return '[Promise]';
if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`;
if (ancestors.has(value)) return '[Circular]';
if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]';
ancestors.add(value);
try {
if (value instanceof Map) {
const shown = Array.from(value.entries()).slice(0, MAX_ITEMS);
const entries = shown.map(
([k, v]) =>
`${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`,
);
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
return block(`Map(${value.size}) {`, '}', entries, pad);
}
if (value instanceof Set) {
const shown = Array.from(value.values()).slice(0, MAX_ITEMS);
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
return block(`Set(${value.size}) {`, '}', entries, pad);
}
if (Array.isArray(value)) {
const shown = value.slice(0, MAX_ITEMS);
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`);
return block('[', ']', entries, pad);
}
const keys = Object.keys(value);
const shown = keys.slice(0, MAX_ITEMS);
const entries = shown.map(
(key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`,
);
if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`);
const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : '';
return block(`${label}{`, '}', entries, pad);
} finally {
ancestors.delete(value);
}
}
/**
* Formats an automat state for the overlay. Serialization failures are surfaced
* instead of being swallowed.
*
* @param {*} value Automat state.
* @returns {string} Display text.
*/
export function formatState(value) {
try {
return serialize(value, 0, new WeakSet(), '');
} catch (err) {
return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`;
}
}
/**
* Floating dev button plus overlay listing all registered Automats with live
* state, readiness flags, subscriber counts and available actions.
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number}>}
*/
class AutomatInspector extends PureComponent {
constructor(props) {
super(props);
this.state = {
open: false,
names: [],
expanded: {},
query: '',
revision: 0,
};
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
this.unsubs = new Map();
this.frame = null;
this.handleToggle = this.handleToggle.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleRefresh = this.handleRefresh.bind(this);
this.handleToggleAll = this.handleToggleAll.bind(this);
this.handleToggleEntry = this.handleToggleEntry.bind(this);
this.handleQueryChange = this.handleQueryChange.bind(this);
this.handleCopy = this.handleCopy.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.syncRegistry = this.syncRegistry.bind(this);
this.scheduleUpdate = this.scheduleUpdate.bind(this);
}
componentDidMount() {
this.syncRegistry();
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
window.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
window.clearInterval(this.registryTimer);
window.removeEventListener('keydown', this.handleKeyDown);
this.unsubscribeAll();
if (this.frame) cancelAnimationFrame(this.frame);
}
/** Mirrors the registry keys into state and keeps subscriptions in sync. */
syncRegistry() {
const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b));
const changed =
names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]);
if (changed) this.setState({ names });
if (this.state.open) this.subscribeToAll();
}
/**
* Subscribes to every registered automat so the overlay reflects live state.
* Note: subscribing also flushes a pending deferred load of a dirty automat.
*/
subscribeToAll() {
const registry = getRegistry();
for (const [name, automat] of registry) {
if (this.unsubs.has(name)) continue;
this.unsubs.set(name, automat.subscribe(() => this.scheduleUpdate()));
}
for (const [name, unsub] of Array.from(this.unsubs)) {
if (!registry.has(name)) {
unsub();
this.unsubs.delete(name);
}
}
}
unsubscribeAll() {
for (const unsub of this.unsubs.values()) unsub();
this.unsubs.clear();
}
/** Coalesces automat notifications into one render per animation frame. */
scheduleUpdate() {
if (this.frame) return;
this.frame = requestAnimationFrame(() => {
this.frame = null;
this.setState((prev) => ({ revision: prev.revision + 1 }));
});
}
handleKeyDown(event) {
if (!event.altKey || !event.shiftKey) return;
if (event.key?.toLowerCase() !== 'a') return;
event.preventDefault();
this.handleToggle();
}
handleToggle() {
const open = !this.state.open;
this.setState({ open });
if (open) {
this.syncRegistry();
this.subscribeToAll();
} else {
this.unsubscribeAll();
}
}
handleClose() {
this.setState({ open: false });
this.unsubscribeAll();
}
handleRefresh() {
this.syncRegistry();
}
handleToggleAll() {
const { names, expanded } = this.state;
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
this.setState({
expanded: allExpanded
? {}
: Object.fromEntries(names.map((name) => [name, true])),
});
}
/**
* @param {string} name Automat name.
*/
handleToggleEntry(name) {
this.setState((prev) => ({
expanded: { ...prev.expanded, [name]: !prev.expanded[name] },
}));
}
/**
* @param {React.ChangeEvent<HTMLInputElement>} event Change event.
*/
handleQueryChange(event) {
this.setState({ query: event.target.value });
}
/**
* Copies the current state of one automat as text.
*
* @param {string} name Automat name.
* @returns {Promise<void>}
*/
async handleCopy(name) {
const automat = getRegistry().get(name);
if (!automat) return;
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).');
}
await navigator.clipboard.writeText(`${name}\n${formatState(automat.state)}`);
enqueueSnackbar(`State von "${name}" kopiert`, { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
/**
* Renders one automat entry.
*
* @param {import('react-automat').Automat} automat Automat instance.
* @param {boolean} isExpanded Whether the details are visible.
* @returns {React.ReactElement} Entry element.
*/
renderEntry(automat, isExpanded) {
const name = automat.name;
const actionNames = Object.keys(automat.actions ?? {});
return (
<Box
key={name}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.paper',
overflow: 'hidden',
}}
>
<ButtonBase
component="div"
onClick={() => this.handleToggleEntry(name)}
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 1,
px: 1,
py: 0.75,
textAlign: 'left',
}}
>
{isExpanded ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
<Typography
variant="body2"
noWrap
sx={{ fontFamily: 'monospace', fontWeight: 600, flexGrow: 1, minWidth: 0 }}
>
{name}
</Typography>
<Chip size="small" variant="outlined" label={`subs ${automat.subscriberCount}`} />
<Chip
size="small"
color={automat.isReady ? 'success' : 'default'}
variant={automat.isReady ? 'filled' : 'outlined'}
label={automat.isReady ? 'ready' : 'unready'}
/>
{automat.isDirty ? <Chip size="small" color="warning" label="dirty" /> : null}
</ButtonBase>
{isExpanded ? (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
{automat.error ? (
<Chip size="small" color="error" label={`error: ${automat.error?.message ?? automat.error}`} />
) : null}
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
Aktionen ({actionNames.length})
</Typography>
<Tooltip title="State als Text kopieren">
<IconButton size="small" onClick={() => this.handleCopy(name)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
{actionNames.length === 0 ? (
<Typography variant="caption" color="text.secondary">
keine
</Typography>
) : (
actionNames.map((action) => <Chip key={action} size="small" label={action} />)
)}
</Stack>
<Typography variant="caption" color="text.secondary">
State
</Typography>
<Box
component="pre"
sx={{
m: 0,
mt: 0.5,
p: 1,
borderRadius: 1,
bgcolor: 'action.hover',
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 320,
overflow: 'auto',
}}
>
{formatState(automat.state)}
</Box>
</Box>
) : null}
</Box>
);
}
render() {
const { open, names, expanded, query } = this.state;
const registry = getRegistry();
const needle = query.trim().toLowerCase();
const entries = names
.map((name) => registry.get(name))
.filter((automat) => automat && (!needle || automat.name.toLowerCase().includes(needle)));
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
return (
<>
<Badge
badgeContent={names.length}
color="secondary"
overlap="circular"
invisible={names.length === 0}
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }}
>
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
<Fab
size="small"
color="primary"
aria-label="Automat-Inspector umschalten"
aria-pressed={open}
onClick={this.handleToggle}
>
<BugReportIcon fontSize="small" />
</Fab>
</Tooltip>
</Badge>
<Drawer
anchor="right"
open={open}
onClose={this.handleClose}
slotProps={{
transition: { timeout: 0 },
paper: {
sx: {
width: { xs: '100%', sm: 560 },
maxWidth: '100%',
display: 'flex',
flexDirection: 'column',
},
},
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
>
<BugReportIcon fontSize="small" color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
Automat-Inspector
</Typography>
<Chip size="small" label={`${names.length} registriert`} />
<Tooltip title="Registry neu einlesen">
<IconButton size="small" onClick={this.handleRefresh}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
<IconButton size="small" onClick={this.handleToggleAll}>
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ px: 2, py: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Name filtern …"
value={query}
onChange={this.handleQueryChange}
/>
</Box>
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
{entries.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
</Typography>
) : (
<Stack spacing={1}>{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}</Stack>
)}
</Box>
</Drawer>
</>
);
}
}
export default AutomatInspector;

View File

@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
@@ -8,8 +9,15 @@ import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App';
import { store } from './store';
import { getActiveTheme } from './theme';
import './automats/supplierAutomat';
import './index.css';
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
// eliminated in production builds, so this module never ships to users.
const AutomatInspector = import.meta.env.DEV
? lazy(() => import('./devtools/AutomatInspector'))
: null;
async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations();
@@ -36,6 +44,11 @@ createRoot(document.getElementById('root')).render(
<StorageManagerProvider>
<AuthBootstrap>
<App />
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>

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 {
@@ -30,6 +29,7 @@ import {
import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats';
const resultRowSx = {
alignItems: 'center',
@@ -83,6 +83,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 };
@@ -168,10 +172,11 @@ class ArtikelSearchPage extends Component {
componentDidMount() {
document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize);
setLastArticleSku(null);
// 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 +225,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 +245,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

@@ -53,6 +53,7 @@ import {
enableTerminalCategory,
disableTerminalCategory,
} from '../../store/posterminalSlice';
import { setLastArticleSku } from '../../automats';
const gridSx = {
display: 'grid',
@@ -72,6 +73,7 @@ class ArtikelKategorienPage extends Component {
}
componentDidMount() {
setLastArticleSku(null);
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl();

View File

@@ -1,6 +1,12 @@
import { useEffect } from 'react';
import Typography from '@mui/material/Typography';
import { setLastArticleSku } from '../../automats';
export default function ArtikelLagerPage() {
useEffect(() => {
setLastArticleSku(null);
}, []);
return (
<Typography color="text.secondary">Lager — Inhalt folgt.</Typography>
);

View File

@@ -2,6 +2,7 @@ import { Component } from 'react';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { ArticleView } from '../../components/article';
import withSkuRoute from './withSkuRoute';
import { setLastArticleSku } from '../../automats';
class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext;
@@ -17,11 +18,13 @@ class ArtikelSkuPage extends Component {
componentDidMount() {
this.installEscape();
setLastArticleSku(this.props.sku);
if (!this.state.ready) void this.ensureArticle();
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku);
const ready =
this.context.getArticle(this.props.sku) != null &&
this.context.getCategoryState() != null;

View File

@@ -1,7 +1,12 @@
import { PureComponent } from 'react';
import { ManifestList } from '../../components/manifest';
import { setLastDeliveryPath } from '../../automats';
class LieferungListePage extends PureComponent {
componentDidMount() {
setLastDeliveryPath(null);
}
render() {
return <ManifestList kind="delivery" />;
}

View File

@@ -1,5 +1,7 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
@@ -13,10 +15,13 @@ import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../utils/apiShape';
import withNavigate from '../../components/withNavigate';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { setLastDeliveryPath } from '../../automats';
import supplierAutomat from '../../automats/supplierAutomat';
import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat';
function sortSuppliers(suppliers) {
return [...suppliers].sort((a, b) =>
@@ -28,17 +33,32 @@ function draftsFrom(suppliers) {
return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
}
class LieferungLieferantenPage extends PureComponent {
static contextType = StorageManagerContext;
function getInitialParams(props) {
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const prefill =
props.location?.state?.name ??
props.location?.state?.newName ??
searchParams?.get('name') ??
searchParams?.get('newName') ??
deliveryDraftAutomat.state.unmatchedSupplierName ??
'';
const returnTo =
props.location?.state?.returnTo ??
searchParams?.get('returnTo') ??
null;
return { prefill: prefill.trim(), returnTo };
}
class LieferungLieferantenPage extends PureComponent {
constructor(props) {
super(props);
const { prefill } = getInitialParams(props);
this.state = {
status: 'loading',
error: null,
suppliers: [],
drafts: {},
newName: '',
newName: prefill,
busyKey: null,
};
this._cancelled = false;
@@ -48,10 +68,27 @@ class LieferungLieferantenPage extends PureComponent {
}
componentDidMount() {
const { returnTo, prefill } = getInitialParams(this.props);
if (!returnTo) {
setLastDeliveryPath(null);
}
if (prefill && !this.state.newName) {
this.setState({ newName: prefill });
}
this._cancelled = false;
void this.loadSuppliers();
}
componentDidUpdate(prevProps) {
if (prevProps.location !== this.props.location) {
const { prefill } = getInitialParams(this.props);
if (prefill && prefill !== this.state.newName) {
this.setState({ newName: prefill });
}
}
}
componentWillUnmount() {
this._cancelled = true;
}
@@ -69,7 +106,7 @@ class LieferungLieferantenPage extends PureComponent {
async loadSuppliers() {
this.setState({ status: 'loading', error: null });
try {
const list = this.context.suppliers ?? (await this.context.supplierPromise);
const list = await supplierAutomat.actions.load();
if (this._cancelled) return;
this.applySuppliers(list);
} catch (err) {
@@ -83,7 +120,7 @@ class LieferungLieferantenPage extends PureComponent {
}
async refreshFromServer() {
const list = await this.context.updateSupplier();
const list = await supplierAutomat.actions.reload();
if (this._cancelled) return;
this.applySuppliers(list);
}
@@ -92,16 +129,24 @@ class LieferungLieferantenPage extends PureComponent {
const name = this.state.newName.trim();
if (!name || this.state.busyKey != null) return;
const { returnTo } = getInitialParams(this.props);
this.setState({ busyKey: 'new' });
try {
const data = await this.context.supplier.create({ name });
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
const newSupplier = await supplierAutomat.actions.create({ name });
// Assign the new supplier to the active delivery draft & clear unmatched name
deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
deliveryDraftAutomat.actions.setUnmatchedSupplierName(null);
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ newName: '', busyKey: null });
enqueueSnackbar('Lieferant angelegt.', { variant: 'success' });
enqueueSnackbar(`Lieferant „${newSupplier.name || name}“ angelegt.`, { variant: 'success' });
if (returnTo && this.props.navigate) {
this.props.navigate(returnTo);
}
} catch (err) {
if (this._cancelled) return;
notifyError(err);
@@ -109,6 +154,7 @@ class LieferungLieferantenPage extends PureComponent {
}
}
async handleRename(supplier) {
const draft = String(this.state.drafts[supplier.id] ?? '').trim();
if (!draft) {
@@ -121,10 +167,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id });
try {
const data = await this.context.supplier.update(supplier.id, { name: draft });
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.update(supplier.id, { name: draft });
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ busyKey: null });
@@ -147,7 +190,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id });
try {
await this.context.supplier.delete(supplier.id);
await supplierAutomat.actions.remove(supplier.id);
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ busyKey: null });
@@ -163,6 +206,8 @@ class LieferungLieferantenPage extends PureComponent {
const { status, error, suppliers, drafts, newName, busyKey } = this.state;
const busy = busyKey != null;
const { returnTo } = getInitialParams(this.props);
if (status === 'loading') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -181,6 +226,26 @@ class LieferungLieferantenPage extends PureComponent {
<PanelTileGrid>
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
<Stack spacing={2}>
{returnTo ? (
<Alert
severity="info"
sx={{ py: 0.5, alignItems: 'center' }}
action={
<Button
color="inherit"
size="small"
onClick={() => this.props.navigate?.(returnTo)}
>
Zurück zur Lieferung
</Button>
}
>
{newName
? `Lieferantenname „${newName}“ aus Dokument übernommen.`
: 'Neuen Lieferanten für die Lieferung anlegen.'}
</Alert>
) : null}
<Typography variant="body2" color="text.secondary">
Lieferanten der ausgewählten Firma.
</Typography>
@@ -269,4 +334,5 @@ class LieferungLieferantenPage extends PureComponent {
}
}
export default LieferungLieferantenPage;
export default withNavigate(LieferungLieferantenPage);

View File

@@ -1,7 +1,9 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
@@ -19,6 +21,7 @@ import {
lineFromApi,
manifestConfig,
TextEditorDialog,
ArticleSearchDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig';
@@ -28,7 +31,9 @@ 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 { setLastDeliveryPath } from '../../automats';
import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -36,9 +41,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,
@@ -48,11 +54,16 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '',
textEditorOpen: false,
textEditorAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
this.handleLineChange = this.handleLineChange.bind(this);
@@ -63,41 +74,58 @@ class LieferungNeuPage extends PureComponent {
this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.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);
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
}
componentDidMount() {
this.unsubscribe = deliveryEditorAutomat.subscribe(this);
this.unsubscribe = this.automat.subscribe(this);
this._cancelled = false;
if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
void this.loadEdit();
} else {
setLastDeliveryPath('/lieferung/neu');
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);
}
}
}
}
componentWillUnmount() {
this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null;
@@ -110,7 +138,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 +173,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 +182,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 +214,31 @@ class LieferungNeuPage extends PureComponent {
}
handlePartyChange(partyId) {
deliveryEditorAutomat.actions.setDeliveryEntity(partyId);
this.automat.actions.setDeliveryEntity(partyId);
if (partyId != null) {
this.setState({ unmatchedSupplierName: null });
this.automat.actions.setUnmatchedSupplierName(null);
}
}
handleCreateSupplierFromPdf(supplierName) {
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
this.handleClosePdfDialog();
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
}
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 +254,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 +269,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 +279,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 +334,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) {
@@ -303,70 +343,187 @@ class LieferungNeuPage extends PureComponent {
}
}
handleOpenArticleSearchForLine(line) {
this.setState({
articleSearchDialogOpen: true,
articleSearchLine: line,
});
}
handleCloseArticleSearch() {
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
}
async handleAssignArticleForLine(article, line) {
const { partyId } = this.state;
if (!partyId) {
enqueueSnackbar('Bitte wählen Sie zuerst einen Lieferanten aus.', { variant: 'warning' });
return;
}
const internalSku = article?.sku;
if (!internalSku) {
enqueueSnackbar('Artikel hat keine SKU.', { variant: 'error' });
return;
}
const supplierSku = line?.sku;
try {
await this.context.article.addSupplier(internalSku, {
entityId: partyId,
sku: supplierSku || undefined,
pu: line?.pu ? Number(line.pu) : undefined,
stock: line?.amount ? Number(line.amount) : undefined,
netPrice: line?.netprice ? Number(line.netprice) : undefined,
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
});
if (supplierSku) {
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
} else {
this.automat.actions.fetchSupplierArticles(partyId);
}
enqueueSnackbar(
`Lieferant erfolgreich dem Artikel "${internalSku}" zugeordnet.`,
{ variant: 'success' },
);
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
} catch (err) {
console.error('Fehler beim Zuordnen des Artikels:', err);
notifyError(err);
throw err;
}
}
async handleRemoveSupplierArticle(line, supplierArticle) {
const internalSku = supplierArticle?.article?.sku;
const assignmentId = supplierArticle?.id;
if (!internalSku || assignmentId == null) {
enqueueSnackbar('Verknüpfung konnte nicht ermittelt werden.', { variant: 'warning' });
return;
}
try {
await this.automat.actions.unlinkSupplierArticle(internalSku, assignmentId, line?.key);
enqueueSnackbar(
`Verknüpfung zu Artikel "${internalSku}" wurde entfernt.`,
{ variant: 'success' },
);
} catch (err) {
console.error('Fehler beim Lösen der Lieferanten-Verknüpfung:', err);
notifyError(err);
}
}
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 pdfconvert-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 pdfconvert-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 +531,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 +539,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,19 +560,25 @@ class LieferungNeuPage extends PureComponent {
handlePdfDelete(attachment) {
if (!attachment?.id) return;
deliveryEditorAutomat.actions.removeAttachment(attachment.id);
this.automat.actions.removeAttachment(attachment.id);
}
async runParse(file) {
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
});
const appendMessage = (msg) => {
if (this._cancelled) return;
if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
@@ -428,8 +591,8 @@ class LieferungNeuPage extends PureComponent {
const parser = this.context?.delivery?.parseSse;
if (!parser) throw new Error('parseSse nicht verfügbar.');
for await (const { event, data } of parser(file)) {
if (this._cancelled) return;
for await (const { event, data } of parser(file, { signal: abortController.signal })) {
if (this._cancelled || abortController.signal.aborted) return;
switch (event) {
case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) });
@@ -487,9 +650,17 @@ class LieferungNeuPage extends PureComponent {
const docType = result?.type ?? result?.basedata?.manifestType;
const notes = Array.isArray(result?.notes) ? result.notes : [];
const parsedSupplierName = (
result?.supplierName ??
result?.basedata?.lieferantName ??
result?.lieferantName ??
result?.basedata?.supplierName ??
''
).trim();
// If supplierId is null, try matching supplier by name from loaded delivery entities
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = result.basedata.lieferantName.trim().toLowerCase();
if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = parsedSupplierName.toLowerCase();
const matched = this.state.deliveryEntities.find(
(e) =>
e.name &&
@@ -500,24 +671,29 @@ class LieferungNeuPage extends PureComponent {
if (matched) supplierId = matched.id;
}
if (docDate) deliveryEditorAutomat.actions.setDate(docDate);
if (supplierId != null) deliveryEditorAutomat.actions.setDeliveryEntity(supplierId);
const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
this.setState({ unmatchedSupplierName });
this.automat.actions.setUnmatchedSupplierName(unmatchedSupplierName);
if (docDate) this.automat.actions.setDate(docDate);
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
if (extNum != null) {
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;
}
@@ -536,6 +712,17 @@ class LieferungNeuPage extends PureComponent {
}
} catch (err) {
if (this._cancelled) return;
if (abortController.signal.aborted || err.name === 'AbortError') {
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
},
}));
return;
}
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
@@ -544,6 +731,9 @@ class LieferungNeuPage extends PureComponent {
},
}));
} finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) {
this.setState((prev) => ({
pdfUploading: false,
@@ -563,28 +753,157 @@ class LieferungNeuPage extends PureComponent {
await this.runParse(file);
}
handleAbortParse() {
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState((prev) => ({
pdfUploading: false,
pdfDialogData: prev.pdfDialogData
? {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
}
: null,
}));
}
handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false });
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: 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 =
attachment.type === 'application/pdf' ||
attachment.name?.toLowerCase().endsWith('.pdf');
const isText =
attachment.type?.startsWith('text/') ||
attachment.name?.toLowerCase().endsWith('.txt') ||
Boolean(attachment.text);
!isPdf &&
(attachment.type?.startsWith('text/') ||
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() {
@@ -602,7 +921,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,
@@ -654,7 +973,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);
@@ -667,17 +986,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 });
@@ -750,16 +1069,50 @@ class LieferungNeuPage extends PureComponent {
/>
{partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
<Typography color="text.secondary">
{cfg.partyGateMessage}
</Typography>
{this.state.unmatchedSupplierName ? (
<Stack spacing={2} sx={{ p: 1 }}>
<Typography color="text.secondary">
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Typography>
<Box>
<Button
variant="contained"
size="small"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant „{this.state.unmatchedSupplierName}“ anlegen
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary">
{cfg.partyGateMessage}
</Typography>
)}
</PanelTile>
) : (
<>
{partyId == null && lines && lines.length > 0 && (
{partyId == null && (
<PanelTile wide={3} color="#fef3c7">
<Alert severity="warning" sx={{ py: 0.5 }}>
Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.
<Alert
severity="warning"
action={
this.state.unmatchedSupplierName ? (
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant anlegen
</Button>
) : null
}
sx={{ py: 0.5, alignItems: 'center' }}
>
{this.state.unmatchedSupplierName
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
: 'Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.'}
</Alert>
</PanelTile>
)}
@@ -778,6 +1131,9 @@ class LieferungNeuPage extends PureComponent {
onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
/>
</>
)}
@@ -800,13 +1156,25 @@ class LieferungNeuPage extends PureComponent {
open={pdfDialogOpen}
filename={pdfFileName}
data={pdfDialogData}
unmatchedSupplierName={this.state.unmatchedSupplierName}
onCreateSupplier={this.handleCreateSupplierFromPdf}
onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/>
<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}
/>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
</>
);

124
src/store/articleAutomat.js Normal file
View File

@@ -0,0 +1,124 @@
import { Automat, fetchJson } from 'react-automat';
import { article } from '../api/index';
import { requireObject, requireDefined } from '../utils/apiShape';
/**
* Article automat store.
* Registered as window.artikel via the name option.
* State shape: { [sku: string]: article | null }
*
* Usage in PureComponent:
* ```jsx
* import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
*
* class ArticleView extends PureComponent {
* constructor(props) {
* super(props);
* const sku = props.sku;
* const currentArticle = articleAutomat.state[sku] ?? null;
* this.state = { article: currentArticle };
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(sku).catch(console.error);
* }
* }
*
* componentDidMount() {
* this.subscribeToArticle();
* }
*
* componentDidUpdate(prevProps) {
* if (prevProps.sku !== this.props.sku) {
* const currentArticle = articleAutomat.state[this.props.sku] ?? null;
* this.setState({ article: currentArticle });
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(this.props.sku).catch(console.error);
* }
*
* this.subscribeToArticle();
* }
* }
*
* subscribeToArticle() {
* if (this.unsub) this.unsub();
* const selector = createArticleSelector(this.props.sku);
* this.unsub = articleAutomat.subscribe(this, (state) => ({
* article: selector(state),
* }));
* }
*
* componentWillUnmount() {
* if (this.unsub) {
* this.unsub();
* this.unsub = null;
* }
* }
*
* render() {
* const { article } = this.state;
* if (!article) return null;
* return <div>{article.name}</div>;
* }
* }
* ```
*/
const articleAutomat = new Automat(
{},
{
/**
* Load article by SKU and update the store.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await article.getBySku(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
articleAutomat.setState({
[sku]: res.article,
});
return res.article;
},
/**
* Update article state after a successful mutation.
* @param {object} article
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
articleAutomat.setState({
[article.sku]: article,
});
},
/**
* Clear article from store (e.g., after deletion).
* @param {string} sku
*/
clearArticle(sku) {
articleAutomat.setState({
[sku]: null,
});
},
},
{
name: 'artikel',
}
);
/**
* Create a selector function for a specific SKU.
* This avoids closure issues when the SKU changes.
* @param {string} sku - The article SKU
* @returns {(state: object) => object|null} Selector function
*/
export function createArticleSelector(sku) {
return (state) => state[sku] ?? null;
}
export default articleAutomat;

View File

@@ -1,109 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { auth, company } from '../api';
export const bootstrapSession = createAsyncThunk('auth/bootstrap', async (session) => {
if (session) return session;
return auth.me();
});
export const loginWithGoogle = createAsyncThunk('auth/login', async (credential) => {
const { session } = await auth.loginWithGoogle(credential);
if (!session?.user) {
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
}
return session;
});
export const selectCompany = createAsyncThunk('auth/selectCompany', async (companyId) => {
const data = await company.select(companyId);
return data.user ?? { selectedCompanyId: companyId };
});
export const createCompany = createAsyncThunk('auth/createCompany', async (payload) => {
const data = await company.create(payload);
if (data.user) return data.user;
if (data.company?.id) {
const selected = await company.select(data.company.id);
return selected.user ?? { selectedCompanyId: data.company.id };
}
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
});
export const logout = createAsyncThunk('auth/logout', async () => {
await auth.logout();
window.history.replaceState(null, '', '/');
});
function mergeUser(state, incoming) {
if (!incoming) return state.user;
const selectedCompanyId =
incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null;
return { ...state.user, ...incoming, selectedCompanyId };
}
const initialState = {
user: null,
googleClientId: null,
status: 'loading',
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(bootstrapSession.fulfilled, (state, action) => {
const session = action.payload;
state.googleClientId = session.googleClientId ?? state.googleClientId;
if (!session.user) {
if (state.status !== 'authed') {
state.user = null;
state.status = 'anon';
}
return;
}
state.user = mergeUser(state, session.user);
state.status = 'authed';
})
.addCase(bootstrapSession.rejected, (state) => {
if (state.status === 'loading') {
state.user = null;
state.status = 'anon';
}
})
.addCase(loginWithGoogle.fulfilled, (state, action) => {
const session = action.payload;
state.user = session.user;
state.googleClientId = session.googleClientId ?? state.googleClientId;
state.status = 'authed';
})
.addCase(loginWithGoogle.rejected, (state) => {
state.user = null;
state.status = 'anon';
})
.addCase(selectCompany.fulfilled, (state, action) => {
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.fulfilled, (state, action) => {
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.rejected, (state, action) => {
console.error('[redux] createCompany.rejected', action.error?.message, action.payload);
})
.addCase(logout.fulfilled, (state) => {
state.user = null;
state.status = 'anon';
});
},
});
export const selectAuthStatus = (state) => state.auth.status;
export const selectAuthUser = (state) => state.auth.user;
export const selectGoogleClientId = (state) => state.auth.googleClientId;
export const selectNeedsCompanySetup = (state) =>
state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null;
export default authSlice.reducer;

View File

@@ -1,14 +1,12 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { brand as brandApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import { catchBackendError } from '../utils/backendError';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
function sortBrands(brands) {
return [...brands].sort((a, b) =>
@@ -71,9 +69,8 @@ const brandSlice = createSlice({
row,
]);
})
.addCase(selectCompany.fulfilled, () => initialState)
.addCase(createCompany.fulfilled, () => initialState)
.addCase(logout.fulfilled, () => initialState);
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
@@ -84,12 +81,9 @@ export const selectBrandsError = (state) => state.brand.error;
export function brandLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadBrands());
}

View File

@@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { category, article } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice';
import { AUTH_LOGGED_OUT } from '../automats/authAutomat';
import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
@@ -645,7 +645,7 @@ const categorySlice = createSlice({
};
}
})
.addCase(logout.fulfilled, () => initialState);
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});

View File

@@ -1,18 +1,16 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { normalizeCompany } from '../utils/companyForm';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async (_, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
async () => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
@@ -21,8 +19,8 @@ export const loadCurrentCompany = createAsyncThunk(
export const updateCompany = createAsyncThunk(
'company/update',
async (payload, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
async (payload) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
@@ -66,7 +64,7 @@ const companySlice = createSlice({
state.status = 'failed';
state.error = action.error.message;
})
.addCase(logout.fulfilled, (state) => {
.addCase(AUTH_LOGGED_OUT, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
@@ -87,12 +85,9 @@ export const selectCompanyRootCategoryId = (state) => state.company.current?.roo
export function companyLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadCurrentCompany());
}

View File

@@ -1,5 +1,5 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import { setAuthEventDispatcher } from '../automats/authAutomat';
import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
@@ -25,16 +25,10 @@ function reduxDevLogger() {
) {
return next(action);
}
const before = storeApi.getState();
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
auth: {
selectedCompanyId: after.auth.user?.selectedCompanyId ?? null,
needsCompanySetup:
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
},
company: after.company.current,
brands: after.brand.brands.length,
posterminals: after.posterminal.terminals.length,
@@ -46,7 +40,6 @@ function reduxDevLogger() {
export const store = configureStore({
reducer: {
auth: authReducer,
company: companyReducer,
category: categoryReducer,
brand: brandReducer,
@@ -58,3 +51,5 @@ export const store = configureStore({
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});
setAuthEventDispatcher(store.dispatch);

View File

@@ -1,14 +1,12 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { posterminal } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
import { loadCurrentCompany } from './companySlice';
/**
@@ -236,7 +234,7 @@ const posterminalSlice = createSlice({
}
})
// Reset on logout
.addCase(logout.fulfilled, () => initialState);
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
@@ -249,12 +247,9 @@ export const { resetPosterminalState } = posterminalSlice.actions;
export function posterminalLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
const companyId = authAutomat.state.user?.selectedCompanyId;
console.log('[posterminalLoadListener]', action.type, { companyId });
if (companyId) {
api.dispatch(loadPosterminals());

View File

@@ -1,13 +1,11 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { unit as unitApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
function sortUnits(units) {
return [...units].sort((a, b) => {
@@ -69,9 +67,8 @@ const unitSlice = createSlice({
.addCase(deleteUnit.fulfilled, (state, action) => {
state.units = state.units.filter((u) => u.si !== action.payload);
})
.addCase(selectCompany.fulfilled, () => initialState)
.addCase(createCompany.fulfilled, () => initialState)
.addCase(logout.fulfilled, () => initialState);
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
@@ -82,12 +79,9 @@ export const selectUnitsError = (state) => state.unit.error;
export function unitLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadUnits());
}

View File

@@ -1,7 +1,8 @@
import { pdfToText } from 'pdftotext-wasm';
import { pdfToText, splitPdfPages } from 'pdfconvert-wasm';
export async function extractPdfText(pdfSource, options = { layout: true }) {
return pdfToText(pdfSource, options);
}
export { pdfToText };
export { pdfToText, splitPdfPages };

View File

@@ -1,36 +1,22 @@
import * as pdfjsLib from 'pdfjs-dist';
// Tells Vite/Webpack to emit the worker script as an asset file locally
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
import { pdfToPng } from 'pdfconvert-wasm';
export async function generatePdfThumbnail(pdfSource, targetWidth = 200) {
let loadingTask;
if (pdfSource instanceof File || pdfSource instanceof Blob) {
const arrayBuffer = await pdfSource.arrayBuffer();
loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
} else {
loadingTask = pdfjsLib.getDocument(pdfSource);
const images = await pdfToPng(pdfSource, {
firstPage: 1,
singleFile: true,
scaleToX: targetWidth,
});
if (!images || images.length === 0) {
return null;
}
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = targetWidth / unscaledViewport.width;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
return canvas.toDataURL('image/jpeg', 0.85);
const blob = new Blob([images[0].data], { type: 'image/png' });
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}