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", "@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1", "@shopify/draggable": "^1.2.1",
"automat": "file:../automat",
"dashgui": "file:../dashgui", "dashgui": "file:../dashgui",
"notistack": "^3.0.2", "notistack": "^3.0.2",
"pdfjs-dist": "^6.3.289", "pdfconvert-wasm": "^0.3.0",
"pdftotext-wasm": "file:../pdftotext-wasm",
"react": "^19.3.0", "react": "^19.3.0",
"react-automat": "^0.1.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
@@ -35,17 +34,6 @@
"vite-plugin-pwa": "^1.3.0" "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": { "../dashgui": {
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
@@ -68,13 +56,6 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
} }
}, },
"../pdftotext-wasm": {
"version": "0.1.0",
"license": "GPLv3",
"bin": {
"pdftotext-wasm": "bin.js"
}
},
"node_modules/@apideck/better-ajv-errors": { "node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7", "version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
@@ -442,9 +423,9 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.8", "version": "7.29.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.8" "@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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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": { "node_modules/@oxc-project/types": {
"version": "0.149.0", "version": "0.150.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -2423,9 +2139,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2440,9 +2156,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2457,9 +2173,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2474,9 +2190,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2491,9 +2207,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2508,9 +2224,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2525,9 +2241,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2545,9 +2261,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2565,9 +2281,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2585,9 +2301,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2605,9 +2321,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2625,9 +2341,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2645,9 +2361,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2662,9 +2378,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2679,9 +2395,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3455,10 +3171,6 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/automat": {
"resolved": "../automat",
"link": true
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3543,9 +3255,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.23", "version": "2.11.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
"integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3569,9 +3281,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.9", "version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3589,11 +3301,11 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.20", "baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810", "caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.420", "electron-to-chromium": "^1.5.427",
"node-releases": "^2.0.54", "node-releases": "^2.0.55",
"update-browserslist-db": "^1.3.2" "update-browserslist-db": "^1.3.3"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -4039,9 +3751,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.428", "version": "1.5.433",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz",
"integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==", "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4285,9 +3997,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4888,12 +4600,12 @@
} }
}, },
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.2", "version": "2.17.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hasown": "^2.0.3" "hasown": "^2.0.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5745,9 +5457,9 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.55", "version": "2.0.56",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -5977,9 +5689,9 @@
} }
}, },
"node_modules/path-scurry/node_modules/lru-cache": { "node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.5.2", "version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -5995,21 +5707,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pdfjs-dist": { "node_modules/pdfconvert-wasm": {
"version": "6.3.289", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", "resolved": "https://registry.npmjs.org/pdfconvert-wasm/-/pdfconvert-wasm-0.3.0.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", "integrity": "sha512-FbEk+Grg0UST4kOPuN7OFfVsut1FvDOJNPS3pt4rzCS8VlFsFzD7ptg7YtFZjEOKgJ4MgqlfCgjK4RYaRBpT0Q==",
"license": "Apache-2.0", "license": "GPLv3"
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/pdftotext-wasm": {
"resolved": "../pdftotext-wasm",
"link": true
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
@@ -6136,6 +5838,20 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
@@ -6336,9 +6052,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/regjsparser": { "node_modules/regjsparser": {
"version": "0.13.2", "version": "0.13.3",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz",
"integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@@ -6412,13 +6128,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.149.0", "@oxc-project/types": "=0.150.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -6428,21 +6144,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm-eabi": "1.2.9",
"@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-android-arm64": "1.2.9",
"@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.9",
"@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.9",
"@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
"@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.9",
"@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.9",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.9",
"@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.9",
"@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.9",
"@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.9",
"@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.9",
"@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.9",
"@rolldown/binding-win32-x64-msvc": "1.2.8" "@rolldown/binding-win32-x64-msvc": "1.2.9"
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
@@ -7095,19 +6811,18 @@
} }
}, },
"node_modules/typed-array-byte-offset": { "node_modules/typed-array-byte-offset": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz",
"integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"available-typed-arrays": "^1.0.7", "available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8", "call-bind": "^1.0.9",
"for-each": "^0.3.3", "for-each": "^0.3.5",
"gopd": "^1.2.0", "gopd": "^1.2.0",
"has-proto": "^1.2.0",
"is-typed-array": "^1.1.15", "is-typed-array": "^1.1.15",
"reflect.getprototypeof": "^1.0.9" "reflect.getprototypeof": "^1.0.10"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -7480,9 +7195,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.22", "version": "1.1.24",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz",
"integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -22,12 +22,11 @@
"@mui/material": "^9.4.0", "@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1", "@shopify/draggable": "^1.2.1",
"automat": "file:../automat",
"dashgui": "file:../dashgui", "dashgui": "file:../dashgui",
"notistack": "^3.0.2", "notistack": "^3.0.2",
"pdfjs-dist": "^6.3.289", "pdfconvert-wasm": "^0.3.0",
"pdftotext-wasm": "file:../pdftotext-wasm",
"react": "^19.3.0", "react": "^19.3.0",
"react-automat": "^0.1.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",

View File

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

View File

@@ -105,19 +105,25 @@ export class ApiClient {
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }), update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
/** DELETE /api/delivery/{id} → 204 */ /** DELETE /api/delivery/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`), delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
/** 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" * POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename * Accepts PDF or text files (max 50 MB), detection via mimetype/filename
* Returns an async generator that yields SSE event objects: * Returns an async generator that yields SSE event objects:
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object } * { 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(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', { const res = await fetch('/api/delivery/parse', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: formData, body: formData,
signal,
}); });
if (!res.ok) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
@@ -133,25 +139,56 @@ export class ApiClient {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
let currentEvent = 'message'; let currentEvent = 'message';
while (true) {
const { done, value } = await reader.read(); const onAbort = () => {
if (done) break; try {
buf += decoder.decode(value, { stream: true }); reader.cancel();
const lines = buf.split('\n'); } catch { /* ignore */ }
buf = lines.pop(); // keep incomplete last line };
for (const line of lines) { if (signal) {
if (line.startsWith('event:')) { if (signal.aborted) {
currentEvent = line.slice(6).trim(); onAbort();
} else if (line.startsWith('data:')) { return;
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
} }
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 { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice'; import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient'; import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat';
/** /**
* Stale-while-revalidate cache: readers keep the current promise; update() * Stale-while-revalidate cache: readers keep the current promise; update()
@@ -102,6 +103,10 @@ export class StorageManager extends ApiClient {
requireDefined(article.sku, 'article.sku'); requireDefined(article.sku, 'article.sku');
const key = String(article.sku); const key = String(article.sku);
this.#articlesBySku.set(key, article); this.#articlesBySku.set(key, article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key); const watchers = this.#articleWatchers.get(key);
if (watchers) { if (watchers) {
for (const onArticle of watchers) onArticle(article); for (const onArticle of watchers) onArticle(article);
@@ -329,6 +334,10 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => { #onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length); 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)) { if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err)); 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 companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree()); const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals()); const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () => const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(), this.#loadCustomergroups(),
@@ -415,11 +423,6 @@ export class StorageManager extends ApiClient {
posterminalPromise: { get: () => posterminalCache.promise }, posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update }, 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 }, deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update }, updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */ /** Settled GET /delivery list, or undefined. */
@@ -571,12 +574,6 @@ export class StorageManager extends ApiClient {
return requireArray(data.posterminals, 'posterminals'); 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() { async #loadDeliveries() {
const data = await this.delivery.list(); const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery'); requireObject(data, 'GET /api/delivery');

View File

@@ -682,6 +682,104 @@ export const delivery = {
return http(`/api/delivery/${id}`, { method: 'DELETE' }); 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 { createContext, createElement, useContext, useRef } from 'react';
import { StorageManager } from './StorageManager'; 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. * Provider — mount once near the top of the tree.
@@ -9,7 +18,7 @@ export const StorageManagerContext = createContext(null);
export function StorageManagerProvider({ children }) { export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null); const mgrRef = useRef(null);
if (!mgrRef.current) { if (!mgrRef.current) {
mgrRef.current = new StorageManager(); mgrRef.current = getStorageManager();
} }
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children); 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. * Hook for function components that need the store manager.
*/ */
export function useStorageManager() { export function useStorageManager() {
const ctx = useContext(StorageManagerContext); const ctx = useContext(StorageManagerContext) || getStorageManager();
if (!ctx) {
throw new Error('useStorageManager must be used within a <StorageManagerProvider>');
}
return ctx; 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 { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api'; 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 = {}) { export function createEmptyLineitem(line = {}) {
return { return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, 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 = {}) { export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
const map = { ...existingMap }; const map = { ...existingMap };
for (const item of articles) { for (const item of articles) {
@@ -34,7 +95,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
map[artSku.toLowerCase()] = item; map[artSku.toLowerCase()] = item;
} }
} }
// Map by EANs // Map by Eans
const eans = item.article?.eans ?? item.article?.articleean ?? []; const eans = item.article?.eans ?? item.article?.articleean ?? [];
for (const ean of eans) { for (const ean of eans) {
if (ean != null) { if (ean != null) {
@@ -49,6 +110,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
return map; return map;
} }
/** @type {Omit<DeliveryEditorState, 'deliveryEntities'>} */
export const initialDelivery = { export const initialDelivery = {
id: null, id: null,
deliveryId: null, deliveryId: null,
@@ -69,344 +131,513 @@ export const initialDelivery = {
supplierArticlesByLineKey: {}, supplierArticlesByLineKey: {},
supplierArticlesLoading: false, supplierArticlesLoading: false,
loadingLineKeys: {}, loadingLineKeys: {},
busySupplierArticleLineKeys: {},
supplierArticlesError: null, supplierArticlesError: null,
unmatchedSupplierName: null,
}; };
/** @type {DeliveryEditorState} */
export const initialState = { export const initialState = {
deliveryEntities: [], deliveryEntities: [],
...initialDelivery, ...initialDelivery,
}; };
export const deliveryEditorAutomat = new Automat(initialState, { /**
setId(id) { * Creates a delivery editor automat or returns the already registered named
deliveryEditorAutomat.setState({ id, deliveryId: id }); * 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) { let automat;
deliveryEditorAutomat.setState({ deliveryEntities: deliveryEntities ?? [] }); const actions = {
}, /** @param {string|number|null} id @returns {void} */
setId(id) {
automat.setState({ id, deliveryId: id });
},
setDelivery(delivery = {}) { /** @param {Array<Object>} deliveryEntities @returns {void} */
const rawLines = delivery.lines ?? delivery.lineitems ?? []; setDeliveryEntities(deliveryEntities) {
const lines = Array.isArray(rawLines) ? rawLines : []; automat.setState({ deliveryEntities: deliveryEntities ?? [] });
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 : [];
deliveryEditorAutomat.setState({ /**
...initialDelivery, * Replaces the edited delivery and normalizes API aliases.
...delivery, * @param {Partial<DeliveryEditorState>} [delivery]
id, * @returns {void}
deliveryId: id, */
partyId, setDelivery(delivery = {}) {
deliveryEntity: partyId, const rawLines = delivery.lines ?? delivery.lineitems ?? [];
externalNumber: extNum, const lines = Array.isArray(rawLines) ? rawLines : [];
external_number: extNum, const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null;
lines, const extNum = delivery.externalNumber ?? delivery.external_number ?? null;
lineitems: lines, const id = delivery.id ?? delivery.deliveryId ?? null;
notes, const notes = Array.isArray(delivery.notes)
attachments, ? delivery.notes
files: attachments, : delivery.notes ? [delivery.notes] : [];
}); const rawAttachments = delivery.attachments ?? delivery.files ?? [];
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
if (partyId && lines.length > 0) { automat.setState({
deliveryEditorAutomat.actions.fetchSupplierArticles(partyId, lines); ...initialDelivery,
} ...delivery,
}, id,
deliveryId: id,
partyId,
deliveryEntity: partyId,
externalNumber: extNum,
external_number: extNum,
lines,
lineitems: lines,
notes,
attachments,
files: attachments,
});
setDate(date) { if (partyId && lines.length > 0) {
deliveryEditorAutomat.setState({ date }); automat.actions.fetchSupplierArticles(partyId, lines);
}, }
},
setDeliveryEntity(entityId) { /** @param {string|null} date @returns {void} */
const prevId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; setDate(date) {
const idChanged = prevId !== entityId; automat.setState({ date });
deliveryEditorAutomat.setState({ },
deliveryEntity: entityId,
partyId: entityId, /**
...(idChanged * Selects a delivery party and refreshes its supplier articles.
? { * @param {string|number|null} entityId
supplierArticles: [], * @returns {void}
supplierArticlesMap: {}, */
supplierArticlesByLineKey: {}, setDeliveryEntity(entityId) {
loadingLineKeys: {}, 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) { automat.setState({
deliveryEditorAutomat.actions.fetchSupplierArticles(entityId); supplierArticles: articles,
} supplierArticlesMap: map,
}, supplierArticlesByLineKey: byLineKey,
supplierArticlesLoading: false,
setPartyId(partyId) { loadingLineKeys: {},
deliveryEditorAutomat.actions.setDeliveryEntity(partyId); });
}, } catch (err) {
console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err);
setExternalNumber(externalNumber) { automat.setState({
deliveryEditorAutomat.setState({ supplierArticlesLoading: false,
externalNumber, supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel',
external_number: externalNumber, });
});
},
setNumber(number) {
deliveryEditorAutomat.setState({ number });
},
setType(type) {
deliveryEditorAutomat.setState({ type });
},
setLines(lines) {
const cleanLines = lines ?? [];
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
},
setLineitems(lines) {
const cleanLines = lines ?? [];
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
},
setNotes(notes) {
const cleanNotes = Array.isArray(notes)
? notes
: notes ? [notes] : [];
deliveryEditorAutomat.setState({ notes: cleanNotes });
},
addLineitem(line = {}) {
const item = createEmptyLineitem(line);
const nextLines = [...(deliveryEditorAutomat.state.lines ?? []), item];
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
},
addLine(line = {}) {
deliveryEditorAutomat.actions.addLineitem(line);
},
updateLineitem(payload) {
const { index, key, ...fields } = payload || {};
const currentLines = deliveryEditorAutomat.state.lines ?? [];
let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1;
if (targetIndex === -1 && key != null) {
targetIndex = currentLines.findIndex((l) => l.key === key);
}
if (targetIndex >= 0 && targetIndex < currentLines.length) {
const nextLines = [...currentLines];
nextLines[targetIndex] = {
...nextLines[targetIndex],
...fields,
};
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
}
},
updateLine(payload) {
deliveryEditorAutomat.actions.updateLineitem(payload);
},
removeLineitem(payload) {
const currentLines = deliveryEditorAutomat.state.lines ?? [];
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
let targetKey = null;
if (index === -1 && payload != null) {
const key = typeof payload === 'string' ? payload : payload?.key;
if (key != null) {
index = currentLines.findIndex((l) => l.key === key);
targetKey = key;
} }
} },
if (index >= 0 && index < currentLines.length) {
targetKey = targetKey ?? currentLines[index]?.key; /**
const nextLines = currentLines.filter((_, i) => i !== index); * Loads and matches a supplier article for one line SKU.
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; * @param {string|number|null} sku SKU to query.
if (targetKey != null) delete byLineKey[targetKey]; * @param {string|number} [lineKey] Editor line key receiving the match.
deliveryEditorAutomat.setState({ * @returns {Promise<void>}
lines: nextLines, */
lineitems: nextLines, async fetchSupplierArticleForSku(sku, lineKey) {
supplierArticlesByLineKey: byLineKey, 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) { try {
deliveryEditorAutomat.actions.removeLineitem(payload); const res = await supplierApi.getArticles(entityId, [trimmed]);
}, const articles = Array.isArray(res?.articles) ? res.articles : [];
async fetchSupplierArticles(targetEntityId, targetLines) { const nextMap = buildSupplierArticlesMap(articles, automat.state.supplierArticlesMap);
const entityId = targetEntityId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null;
if (!entityId) {
deliveryEditorAutomat.setState({
supplierArticles: [],
supplierArticlesMap: {},
supplierArticlesByLineKey: {},
supplierArticlesLoading: false,
loadingLineKeys: {},
});
return;
}
const lines = targetLines ?? deliveryEditorAutomat.state.lines ?? []; const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
const skus = Array.from( if (lineKey != null) {
new Set( byLineKey[lineKey] = matched;
lines }
.map((l) => (l.sku != null ? String(l.sku).trim() : ''))
.filter(Boolean),
),
);
deliveryEditorAutomat.setState({ const currentList = automat.state.supplierArticles || [];
supplierArticlesLoading: true, const mergedList = [...currentList];
supplierArticlesError: null, for (const a of articles) {
}); if (!mergedList.some((existing) => existing.id === a.id)) {
mergedList.push(a);
}
}
try { const nextLoading = { ...(automat.state.loadingLineKeys || {}) };
const res = await supplierApi.getArticles(entityId, skus); if (lineKey != null) delete nextLoading[lineKey];
const articles = Array.isArray(res?.articles) ? res.articles : [];
const map = buildSupplierArticlesMap(articles); automat.setState({
const byLineKey = {}; supplierArticles: mergedList,
for (const line of lines) { supplierArticlesMap: nextMap,
if (line.key != null) { supplierArticlesByLineKey: byLineKey,
const rawSku = line.sku != null ? String(line.sku).trim() : ''; loadingLineKeys: nextLoading,
byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null; 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({ /** @param {Object} attachment @returns {void} */
supplierArticles: articles, addAttachment(attachment) {
supplierArticlesMap: map, const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
supplierArticlesByLineKey: byLineKey, const item = { ...attachment, id };
supplierArticlesLoading: false, const next = [...(automat.state.attachments ?? []), item];
loadingLineKeys: {}, automat.setState({ attachments: next, files: next });
}); },
} catch (err) {
console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err);
deliveryEditorAutomat.setState({
supplierArticlesLoading: false,
supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel',
});
}
},
async fetchSupplierArticleForSku(sku, lineKey) { /** @param {Array<Object>} attachments @returns {void} */
const entityId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; setAttachments(attachments) {
const trimmed = sku != null ? String(sku).trim() : ''; const clean = Array.isArray(attachments) ? attachments : [];
automat.setState({ attachments: clean, files: clean });
},
if (!entityId || !trimmed) { /** @param {{id: string|number, [key: string]: *}} payload @returns {void} */
if (lineKey != null) { updateAttachment(payload) {
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; const { id, ...fields } = payload || {};
byLineKey[lineKey] = null; const current = automat.state.attachments ?? [];
deliveryEditorAutomat.setState({ supplierArticlesByLineKey: byLineKey }); 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 || {}; /** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */
deliveryEditorAutomat.setState({ removeAttachment(idOrPayload) {
loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading, const current = automat.state.attachments ?? [];
}); const targetId =
typeof idOrPayload === 'object' && idOrPayload !== null
try { ? (idOrPayload.id ?? idOrPayload.fileId)
const res = await supplierApi.getArticles(entityId, [trimmed]); : idOrPayload;
const articles = Array.isArray(res?.articles) ? res.articles : []; const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId));
let next;
const nextMap = buildSupplierArticlesMap(articles, deliveryEditorAutomat.state.supplierArticlesMap); if (hasMatch) {
const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null; next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
} else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) {
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) }; next = current.filter((_, idx) => idx !== idOrPayload);
if (lineKey != null) { } else {
byLineKey[lineKey] = matched; next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
} }
automat.setState({ attachments: next, files: next });
},
const currentList = deliveryEditorAutomat.state.supplierArticles || []; /** Resets delivery fields while retaining the available entities. @returns {void} */
const mergedList = [...currentList]; resetDelivery() {
for (const a of articles) { automat.setState({
if (!mergedList.some((existing) => existing.id === a.id)) { ...initialDelivery,
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,
}); });
} catch (err) { },
console.error('Fehler beim Abrufen des Artikels für SKU:', err);
const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) }; /** Resets the complete editor state. @returns {void} */
if (lineKey != null) delete nextLoading[lineKey]; resetDeliveryEditor() {
deliveryEditorAutomat.setState({ automat.setState({
loadingLineKeys: nextLoading, ...initialState,
supplierArticlesLoading: Object.keys(nextLoading).length > 0,
}); });
} },
}, };
addAttachment(attachment) { automat = new Automat(initialState, actions, {
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; name,
const item = { ...attachment, id }; persist: false,
const next = [...(deliveryEditorAutomat.state.attachments ?? []), item]; });
deliveryEditorAutomat.setState({ attachments: next, files: next });
},
setAttachments(attachments) { return automat;
const clean = Array.isArray(attachments) ? attachments : []; }
deliveryEditorAutomat.setState({ attachments: clean, files: clean });
},
updateAttachment(payload) { /** Shared automat used for new, unsaved deliveries. */
const { id, ...fields } = payload || {}; export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
const current = deliveryEditorAutomat.state.attachments ?? []; /** Backward-compatible alias of {@link deliveryDraftAutomat}. */
const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id)); export const deliveryEditorAutomat = deliveryDraftAutomat;
if (idx !== -1) {
const next = [...current];
next[idx] = { ...next[idx], ...fields };
deliveryEditorAutomat.setState({ attachments: next, files: next });
}
},
removeAttachment(idOrPayload) { /**
const current = deliveryEditorAutomat.state.attachments ?? []; * Returns the draft automat or the named automat belonging to a saved delivery.
const targetId = *
typeof idOrPayload === 'object' && idOrPayload !== null * @param {string|number|null} docId Delivery identifier; `null` selects the draft.
? (idOrPayload.id ?? idOrPayload.fileId) * @returns {Automat<DeliveryEditorState>} Delivery-specific editor automat.
: idOrPayload; */
const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId)); export function getDeliveryEditorAutomat(docId) {
let next; if (docId == null) {
if (hasMatch) { return deliveryDraftAutomat;
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); }
} else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) { const name = `delivery-${docId}`;
next = current.filter((_, idx) => idx !== idOrPayload); let automat = Automat.get(name);
} else { if (!automat) {
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId)); automat = createDeliveryEditorAutomat({ name });
} }
deliveryEditorAutomat.setState({ attachments: next, files: next }); return automat;
}, }
resetDelivery() {
deliveryEditorAutomat.setState({
...initialDelivery,
});
},
resetDeliveryEditor() {
deliveryEditorAutomat.setState({
...initialState,
});
},
});
export const { export const {
setId, setId,
@@ -429,29 +660,52 @@ export const {
removeLine, removeLine,
fetchSupplierArticles, fetchSupplierArticles,
fetchSupplierArticleForSku, fetchSupplierArticleForSku,
unlinkSupplierArticle,
addAttachment, addAttachment,
setAttachments, setAttachments,
updateAttachment, updateAttachment,
removeAttachment, removeAttachment,
setUnmatchedSupplierName,
resetDelivery, resetDelivery,
resetDeliveryEditor, resetDeliveryEditor,
} = deliveryEditorAutomat.actions; } = deliveryEditorAutomat.actions;
// Selectors // Selectors
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id; export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities; export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date; 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; 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; 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; export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type; export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
/** @param {DeliveryEditorState} [state] @returns {Array<DeliveryLine>} */
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems; export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
/** @param {DeliveryEditorState} [state] @returns {Array<*>} */
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes; export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments; export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
/** @param {DeliveryEditorState} [state] @returns {Array<SupplierArticle>} */
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles; export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle>} */
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap; export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle|null>} */
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey; export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
/** @param {DeliveryEditorState} [state] @returns {boolean} */
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading; export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys; 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; export default deliveryEditorAutomat;

View File

@@ -1 +1,6 @@
export * from './deliveryEditorAutomat'; 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 { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery'; import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath } from '../pages/artikel/categoryRoute'; import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
import { navigationAutomat } from '../automats';
const SIDEBAR_WIDTH = 144; const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 88; const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent { 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() { render() {
const { activeNav, navigate } = this.props; const { activeNav, navigate } = this.props;
const { lastDeliveryPath, lastArticleSku } = this.state;
return ( return (
<Box <Box
@@ -45,7 +66,21 @@ class AppSidebar extends PureComponent {
selected={active} selected={active}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { 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) => ({ sx={(theme) => ({
borderRadius: 0, borderRadius: 0,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { formatPrice } from '../../../utils/price'; import { formatPrice } from '../../../utils/price';
import supplierAutomat from '../../../automats/supplierAutomat';
import ArticleSupplierDialog from '../ArticleSupplierDialog'; import ArticleSupplierDialog from '../ArticleSupplierDialog';
export default class ArticleSupplierTab extends PureComponent { export default class ArticleSupplierTab extends PureComponent {
@@ -80,7 +81,7 @@ export default class ArticleSupplierTab extends PureComponent {
// 2. Fetch available company suppliers and company metadata // 2. Fetch available company suppliers and company metadata
try { try {
const [companySuppliers, company] = await Promise.all([ const [companySuppliers, company] = await Promise.all([
this.context.suppliers ?? this.context.supplierPromise, supplierAutomat.actions.load(),
this.context.company ?? this.context.companyPromise, this.context.company ?? this.context.companyPromise,
]); ]);
this.setState({ 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) { if (onClick && attachment) {
onClick(attachment); onClick(attachment);
} else if (attachment?.file instanceof Blob || attachment?.file instanceof File) { } else if (attachment?.file instanceof Blob || attachment?.file instanceof File) {
const url = URL.createObjectURL(attachment.file); if (attachment.file.size > 0) {
window.open(url, '_blank'); 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 name = attachment?.name || 'Dokument';
const size = attachment?.size; const size = attachment?.size;
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); 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 ( return (
<Box <Box
@@ -125,7 +137,7 @@ export class Attachment extends PureComponent {
letterSpacing: '0.05em', letterSpacing: '0.05em',
}} }}
> >
TXT {isCsv ? 'CSV' : 'TXT'}
</Typography> </Typography>
</Box> </Box>
<Typography <Typography

View File

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

View File

@@ -1,4 +1,5 @@
import { Fragment, PureComponent } from 'react'; import { Fragment, PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
@@ -7,15 +8,19 @@ import IconButton from '@mui/material/IconButton';
import TableCell from '@mui/material/TableCell'; import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined'; import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import AddIcon from '@mui/icons-material/Add'; 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 { LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto'; import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
class ManifestFormLine extends PureComponent { class ManifestFormLine extends PureComponent {
constructor(props) { constructor(props) {
@@ -23,6 +28,8 @@ class ManifestFormLine extends PureComponent {
this.handleRemove = this.handleRemove.bind(this); this.handleRemove = this.handleRemove.bind(this);
this.handleBlur = this.handleBlur.bind(this); this.handleBlur = this.handleBlur.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this); this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
} }
handleRemove() { 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) { renderField(column) {
const { id, align, type } = column; const { id, align, type } = column;
const { line, saving, onChange } = this.props; const { line, saving, onChange } = this.props;
@@ -151,6 +172,9 @@ class ManifestFormLine extends PureComponent {
{/* Internal Article SKU */} {/* Internal Article SKU */}
{article.sku && ( {article.sku && (
<Chip <Chip
component={RouterLink}
to={artikelDetailPath(article)}
clickable
size="small" size="small"
label={`Art.-Nr.: ${article.sku}`} label={`Art.-Nr.: ${article.sku}`}
sx={{ sx={{
@@ -161,6 +185,11 @@ class ManifestFormLine extends PureComponent {
color: 'primary.dark', color: 'primary.dark',
border: '1px solid', border: '1px solid',
borderColor: 'primary.200', borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}} }}
/> />
)} )}
@@ -196,24 +225,6 @@ class ManifestFormLine extends PureComponent {
</Typography> </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) */} {/* Stored Purchase Price (Netto) */}
{priceNet != null && ( {priceNet != null && (
<Typography <Typography
@@ -225,6 +236,21 @@ class ManifestFormLine extends PureComponent {
{taxRate != null ? ` (${taxRate}% MwSt)` : ''} {taxRate != null ? ` (${taxRate}% MwSt)` : ''}
</Typography> </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> </Box>
); );
} }
@@ -258,6 +284,28 @@ class ManifestFormLine extends PureComponent {
Artikel anlegen Artikel anlegen
</Button> </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> </Box>
); );
} }
@@ -298,13 +346,13 @@ class ManifestFormLine extends PureComponent {
</TableRow> </TableRow>
{/* Row 2: Matched Supplier Article Details */} {/* 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 <TableCell
colSpan={columns.length + 1} colSpan={columns.length + 1}
sx={{ sx={{
py: 0.4, py: 0.4,
px: 2, px: 2,
bgcolor: 'rgba(248, 250, 252, 0.75)', bgcolor: 'transparent',
}} }}
> >
{this.renderSecondLine()} {this.renderSecondLine()}

View File

@@ -134,7 +134,7 @@ class ManifestHeader extends PureComponent {
) : null} ) : null}
{onPdfUpload ? ( {onPdfUpload ? (
<FileDropzone <FileDropzone
accept="application/pdf,.pdf" accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
disabled={saving || pdfUploading} disabled={saving || pdfUploading}
onFileDrop={onPdfUpload} onFileDrop={onPdfUpload}
sx={{ sx={{
@@ -150,6 +150,7 @@ class ManifestHeader extends PureComponent {
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />} startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
disabled={saving || pdfUploading} disabled={saving || pdfUploading}
onClick={this.handleUploadClick} onClick={this.handleUploadClick}
title="PDF, TXT oder CSV hochladen"
sx={{ sx={{
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
height: 40, height: 40,
@@ -164,8 +165,8 @@ class ManifestHeader extends PureComponent {
{pdfUploading {pdfUploading
? 'Wird analysiert…' ? 'Wird analysiert…'
: isDragging : isDragging
? 'PDF hier ablegen' ? 'Datei hier ablegen'
: 'PDF hochladen'} : 'Datei hochladen'}
</Button> </Button>
)} )}
</FileDropzone> </FileDropzone>
@@ -182,7 +183,7 @@ class ManifestHeader extends PureComponent {
<input <input
ref={this.fileInputRef} ref={this.fileInputRef}
type="file" type="file"
accept="application/pdf,.pdf" accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={this.handleFileChange} onChange={this.handleFileChange}
/> />

View File

@@ -30,6 +30,9 @@ class ManifestLines extends PureComponent {
loadingLineKeys = {}, loadingLineKeys = {},
onSkuBlur, onSkuBlur,
onCreateArticle, onCreateArticle,
onSearchArticle,
onRemoveSupplierArticle,
busySupplierArticleLineKeys = {},
} = this.props; } = this.props;
return ( return (
@@ -38,7 +41,7 @@ class ManifestLines extends PureComponent {
<TableContainer> <TableContainer>
<Table size="small" stickyHeader> <Table size="small" stickyHeader>
<TableHead> <TableHead>
<TableRow> <TableRow sx={{ '& th': { borderBottom: 'none', fontWeight: 700 } }}>
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableCell
key={col.id} key={col.id}
@@ -47,12 +50,13 @@ class ManifestLines extends PureComponent {
width: col.width, width: col.width,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
bgcolor: linesColor, bgcolor: linesColor,
fontWeight: 700,
}} }}
> >
{col.label} {col.label}
</TableCell> </TableCell>
))} ))}
<TableCell padding="checkbox" sx={{ bgcolor: linesColor }} /> <TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -74,10 +78,13 @@ class ManifestLines extends PureComponent {
saving={saving} saving={saving}
supplierArticle={matched} supplierArticle={matched}
loading={Boolean(isLineLoading)} loading={Boolean(isLineLoading)}
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
onChange={onLineChange} onChange={onLineChange}
onRemove={onRemoveLine} onRemove={onRemoveLine}
onSkuBlur={onSkuBlur} onSkuBlur={onSkuBlur}
onCreateArticle={onCreateArticle} 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 CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; 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; 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 hasResult = Boolean(result);
const isTextDoc =
filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv');
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={streaming ? undefined : onClose} onClose={handleAbortOrClose}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
aria-labelledby="pdf-dialog-title" aria-labelledby="pdf-dialog-title"
@@ -117,16 +132,26 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{error ? ( {aborted ? (
<ErrorOutlinedIcon color="warning" />
) : error ? (
<ErrorOutlinedIcon color="error" /> <ErrorOutlinedIcon color="error" />
) : streaming ? ( ) : streaming ? (
<CircularProgress size={22} /> <CircularProgress size={22} />
) : isTextDoc ? (
<TextSnippetOutlinedIcon color="action" />
) : ( ) : (
<PictureAsPdfIcon color="action" /> <PictureAsPdfIcon color="action" />
)} )}
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" component="div" fontWeight={600}> <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> </Typography>
{filename ? ( {filename ? (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -136,9 +161,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</Box> </Box>
<IconButton <IconButton
aria-label="close" aria-label="close"
onClick={onClose} onClick={handleAbortOrClose}
size="small" size="small"
disabled={streaming}
sx={{ color: (theme) => theme.palette.grey[500] }} sx={{ color: (theme) => theme.palette.grey[500] }}
> >
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
@@ -146,9 +170,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
</DialogTitle> </DialogTitle>
<DialogContent dividers sx={{ p: 2 }}> <DialogContent dividers sx={{ p: 2 }}>
{/* Error */} {/* Error or Aborted Alert */}
{error && ( {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> <Typography variant="body2" fontWeight={600}>{error}</Typography>
</Alert> </Alert>
)} )}
@@ -157,18 +181,69 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
<EventLog messages={messages} streaming={streaming} /> <EventLog messages={messages} streaming={streaming} />
{/* Success notification */} {/* Success notification */}
{hasResult && !streaming && !error && ( {hasResult && !streaming && !error && !aborted && (
<Alert severity="success" sx={{ mt: 2 }}> <Alert severity="success" sx={{ mt: 2 }}>
Daten erfolgreich in das Formular übernommen. Daten erfolgreich in das Formular übernommen.
</Alert> </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> </DialogContent>
<DialogActions sx={{ p: 1.5, px: 2 }}> <DialogActions sx={{ p: 1.5, px: 2 }}>
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}> {streaming ? (
Schließen <Button
</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> </DialogActions>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,6 +1,7 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
@@ -12,10 +13,15 @@ import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
export class TextEditorDialog extends PureComponent { export class TextEditorDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@@ -26,6 +32,7 @@ export class TextEditorDialog extends PureComponent {
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this); this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this); this.handleCopy = this.handleCopy.bind(this);
this.handleDownload = this.handleDownload.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -34,12 +41,16 @@ export class TextEditorDialog extends PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) { 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() { async loadContent() {
const { attachment, open } = this.props; const { attachment, open, docId, onContentLoad } = this.props;
if (!open || !attachment) return; if (!open || !attachment) return;
if (typeof attachment.text === 'string') { if (typeof attachment.text === 'string') {
@@ -51,7 +62,10 @@ export class TextEditorDialog extends PureComponent {
return; 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 }); this.setState({ loading: true });
try { try {
const text = await attachment.file.text(); const text = await attachment.file.text();
@@ -60,11 +74,44 @@ export class TextEditorDialog extends PureComponent {
initialContent: text, initialContent: text,
loading: false, loading: false,
}); });
return;
} catch (err) { } catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err); console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false }); 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) { 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() { render() {
const { open, onClose, attachment, readOnly = false } = this.props; const { open, onClose, attachment, readOnly = false } = this.props;
if (!open) return null; if (!open) return null;
@@ -152,8 +215,24 @@ export class TextEditorDialog extends PureComponent {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
bgcolor: '#fafafa', 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 <TextField
multiline multiline
fullWidth fullWidth
@@ -216,15 +295,26 @@ export class TextEditorDialog extends PureComponent {
justifyContent: 'space-between', justifyContent: 'space-between',
}} }}
> >
<Button <Box sx={{ display: 'flex', gap: 1 }}>
size="small" <Button
variant="outlined" size="small"
startIcon={<ContentCopyIcon />} variant="outlined"
onClick={this.handleCopy} startIcon={<ContentCopyIcon />}
disabled={loading || !content} onClick={this.handleCopy}
> disabled={loading || !content}
Kopieren >
</Button> Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}> <Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'} {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 Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList'; export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog'; export { default as TextEditorDialog } from './TextEditorDialog';
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId'; export { withManifestId } from './withManifestId';
export { export {
manifestConfig, manifestConfig,

View File

@@ -1,3 +1,5 @@
import supplierAutomat from '../../automats/supplierAutomat';
/** Groups that are not sales customers (filter client-side). */ /** Groups that are not sales customers (filter client-side). */
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']); export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
@@ -173,20 +175,6 @@ export async function buildFilesForUpload(attachments = [], { onlyNew = false }
filePayload.thumbavif = thumbBase64; 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); files.push(filePayload);
} catch (err) { } catch (err) {
console.error('Fehler beim Konvertieren von Datei zu Base64:', err); console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
@@ -268,12 +256,9 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
if (apiFile.thumbavif) { if (apiFile.thumbavif) {
try { try {
if (typeof apiFile.thumbavif === 'string') { if (typeof apiFile.thumbavif === 'string') {
if (apiFile.thumbavif.startsWith('data:')) { thumbnail = apiFile.thumbavif.startsWith('data:')
thumbnail = apiFile.thumbavif; ? apiFile.thumbavif
} else { : `data:image/avif;base64,${apiFile.thumbavif}`;
const thumbBlob = base64ToBlob(apiFile.thumbavif, 'image/avif');
thumbnail = URL.createObjectURL(thumbBlob);
}
} else if (typeof apiFile.thumbavif === 'object') { } else if (typeof apiFile.thumbavif === 'object') {
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif; const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) { 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 // 2. Fallback to existing matching attachment thumbnail
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
const matchingExisting = const matchingExisting =
existingMap.get(String(apiFile.id)) || existingMap.get(String(apiFile.id)) ||
existingMap.get(String(apiFile.fileId)) || existingMap.get(String(apiFile.fileId)) ||
@@ -320,8 +288,8 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
? matchingExisting.file ? matchingExisting.file
: new File([''], placeholderName, { type: placeholderType }); : new File([''], placeholderName, { type: placeholderType });
const text = apiFile.meta?.text || matchingExisting?.text || null; const text = matchingExisting?.text || null;
const parentPdfId = apiFile.meta?.parentPdfId || matchingExisting?.parentPdfId || null; const parentPdfId = matchingExisting?.parentPdfId || null;
attachments.push({ attachments.push({
id: apiFile.id, 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({ files.push({
file: fileToUpload, file: fileToUpload,
name: attachment.name || fileToUpload.name, name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type, mimetype: attachment.type || fileToUpload.type,
meta: Object.keys(meta).length > 0 ? meta : undefined,
}); });
} }
return files; return files;
@@ -429,14 +388,6 @@ export async function buildManifestBody(
const isUpdate = !requireAtLeastOne; const isUpdate = !requireAtLeastOne;
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate }); const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) { 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(); const formData = new FormData();
formData.append('data', JSON.stringify(body)); formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) { for (const item of filesToUpload) {
@@ -475,7 +426,10 @@ export const DELIVERY_LINE_COLUMNS = [
*/ */
export async function loadParties(kind, ctx) { export async function loadParties(kind, ctx) {
if (kind === 'delivery') { 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); return sortSuppliers(list);
} }
const [entities, groups] = await Promise.all([ 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) { export default function withNavigate(Wrapped) {
function NavigateWrapper(props) { function NavigateWrapper(props) {
const navigate = useNavigate(); const navigate = useNavigate();
return <Wrapped {...props} navigate={navigate} />; const location = useLocation();
return <Wrapped {...props} navigate={navigate} location={location} />;
} }
return NavigateWrapper; 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 { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@@ -8,8 +9,15 @@ import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App'; import App from './App';
import { store } from './store'; import { store } from './store';
import { getActiveTheme } from './theme'; import { getActiveTheme } from './theme';
import './automats/supplierAutomat';
import './index.css'; 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() { async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations(); const regs = await navigator.serviceWorker.getRegistrations();
@@ -36,6 +44,11 @@ createRoot(document.getElementById('root')).render(
<StorageManagerProvider> <StorageManagerProvider>
<AuthBootstrap> <AuthBootstrap>
<App /> <App />
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</AuthBootstrap> </AuthBootstrap>
</StorageManagerProvider> </StorageManagerProvider>
</SnackbarProvider> </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 Inventory2Icon from '@mui/icons-material/Inventory2';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AssignmentIcon from '@mui/icons-material/Assignment'; 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 RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { isKategorienPath } from './pages/artikel/categoryRoute'; 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 = [ export const NAV_ITEMS = [
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon }, { 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: '/auftrag', label: 'Auftrag', icon: AssignmentIcon },
{ path: '/kunde', label: 'Kunde', icon: PeopleIcon }, { path: '/kunde', label: 'Kunde', icon: PeopleIcon },
{ path: '/infos', label: 'Infos', icon: InfoIcon }, { 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 = { export const SUB_NAV = {
'/artikel': [ '/artikel': [
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon }, { segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
@@ -30,7 +106,7 @@ export const SUB_NAV = {
], ],
'/lieferung': [ '/lieferung': [
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon }, { 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 }, { segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon },
], ],
'/auftrag': [ '/auftrag': [

View File

@@ -9,8 +9,7 @@ import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
import { StorageManagerContext } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { import {
@@ -30,6 +29,7 @@ import {
import { preserveSearchFocus } from './searchFocus'; import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute'; import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams'; import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats';
const resultRowSx = { const resultRowSx = {
alignItems: 'center', alignItems: 'center',
@@ -83,6 +83,10 @@ function initialSearchState(searchParams) {
class ArtikelSearchPage extends Component { class ArtikelSearchPage extends Component {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
get storageManager() {
return this.context || getStorageManager();
}
state = initialSearchState(this.props.searchParams); state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null }; listSlotRef = { current: null };
rowMeasureRef = { current: null }; rowMeasureRef = { current: null };
@@ -168,10 +172,11 @@ class ArtikelSearchPage extends Component {
componentDidMount() { componentDidMount() {
document.addEventListener('mousedown', preserveSearchFocus, true); document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize); window.addEventListener('resize', this.handleWindowResize);
setLastArticleSku(null);
// Kick off initial search (empty input = show all products). // Kick off initial search (empty input = show all products).
const { input } = this.state; const { input } = this.state;
this.context.doSearch({ this.storageManager?.doSearch({
target: input, target: input,
filter: this.props.searchParams.get('filter') || undefined, filter: this.props.searchParams.get('filter') || undefined,
offset: 0, offset: 0,
@@ -220,7 +225,7 @@ class ArtikelSearchPage extends Component {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
this.context.doSearch({ this.storageManager?.doSearch({
target: value, target: value,
filter: this.props.searchParams.get('filter') || undefined, filter: this.props.searchParams.get('filter') || undefined,
offset: 0, offset: 0,
@@ -240,7 +245,7 @@ class ArtikelSearchPage extends Component {
// Delegate fetch + debounce to the global StorageManager. // Delegate fetch + debounce to the global StorageManager.
// The callback pushes results into local component state. // The callback pushes results into local component state.
this.context.doSearch({ this.storageManager?.doSearch({
target: trimmed, target: trimmed,
filter: filter || undefined, filter: filter || undefined,
offset, offset,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -13,10 +15,13 @@ import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext'; import withNavigate from '../../components/withNavigate';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastDeliveryPath } from '../../automats';
import supplierAutomat from '../../automats/supplierAutomat';
import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat';
function sortSuppliers(suppliers) { function sortSuppliers(suppliers) {
return [...suppliers].sort((a, b) => return [...suppliers].sort((a, b) =>
@@ -28,17 +33,32 @@ function draftsFrom(suppliers) {
return Object.fromEntries(suppliers.map((row) => [row.id, row.name])); return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
} }
class LieferungLieferantenPage extends PureComponent { function getInitialParams(props) {
static contextType = StorageManagerContext; 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) { constructor(props) {
super(props); super(props);
const { prefill } = getInitialParams(props);
this.state = { this.state = {
status: 'loading', status: 'loading',
error: null, error: null,
suppliers: [], suppliers: [],
drafts: {}, drafts: {},
newName: '', newName: prefill,
busyKey: null, busyKey: null,
}; };
this._cancelled = false; this._cancelled = false;
@@ -48,10 +68,27 @@ class LieferungLieferantenPage extends PureComponent {
} }
componentDidMount() { componentDidMount() {
const { returnTo, prefill } = getInitialParams(this.props);
if (!returnTo) {
setLastDeliveryPath(null);
}
if (prefill && !this.state.newName) {
this.setState({ newName: prefill });
}
this._cancelled = false; this._cancelled = false;
void this.loadSuppliers(); 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() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
} }
@@ -69,7 +106,7 @@ class LieferungLieferantenPage extends PureComponent {
async loadSuppliers() { async loadSuppliers() {
this.setState({ status: 'loading', error: null }); this.setState({ status: 'loading', error: null });
try { try {
const list = this.context.suppliers ?? (await this.context.supplierPromise); const list = await supplierAutomat.actions.load();
if (this._cancelled) return; if (this._cancelled) return;
this.applySuppliers(list); this.applySuppliers(list);
} catch (err) { } catch (err) {
@@ -83,7 +120,7 @@ class LieferungLieferantenPage extends PureComponent {
} }
async refreshFromServer() { async refreshFromServer() {
const list = await this.context.updateSupplier(); const list = await supplierAutomat.actions.reload();
if (this._cancelled) return; if (this._cancelled) return;
this.applySuppliers(list); this.applySuppliers(list);
} }
@@ -92,16 +129,24 @@ class LieferungLieferantenPage extends PureComponent {
const name = this.state.newName.trim(); const name = this.state.newName.trim();
if (!name || this.state.busyKey != null) return; if (!name || this.state.busyKey != null) return;
const { returnTo } = getInitialParams(this.props);
this.setState({ busyKey: 'new' }); this.setState({ busyKey: 'new' });
try { try {
const data = await this.context.supplier.create({ name }); const newSupplier = await supplierAutomat.actions.create({ name });
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier'); // Assign the new supplier to the active delivery draft & clear unmatched name
requireDefined(data.supplier.id, 'supplier.id'); deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
deliveryDraftAutomat.actions.setUnmatchedSupplierName(null);
await this.refreshFromServer(); await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ newName: '', busyKey: null }); 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) { } catch (err) {
if (this._cancelled) return; if (this._cancelled) return;
notifyError(err); notifyError(err);
@@ -109,6 +154,7 @@ class LieferungLieferantenPage extends PureComponent {
} }
} }
async handleRename(supplier) { async handleRename(supplier) {
const draft = String(this.state.drafts[supplier.id] ?? '').trim(); const draft = String(this.state.drafts[supplier.id] ?? '').trim();
if (!draft) { if (!draft) {
@@ -121,10 +167,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
const data = await this.context.supplier.update(supplier.id, { name: draft }); await supplierAutomat.actions.update(supplier.id, { name: draft });
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await this.refreshFromServer(); await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
@@ -147,7 +190,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
await this.context.supplier.delete(supplier.id); await supplierAutomat.actions.remove(supplier.id);
await this.refreshFromServer(); await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
@@ -163,6 +206,8 @@ class LieferungLieferantenPage extends PureComponent {
const { status, error, suppliers, drafts, newName, busyKey } = this.state; const { status, error, suppliers, drafts, newName, busyKey } = this.state;
const busy = busyKey != null; const busy = busyKey != null;
const { returnTo } = getInitialParams(this.props);
if (status === 'loading') { if (status === 'loading') {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -181,6 +226,26 @@ class LieferungLieferantenPage extends PureComponent {
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={2} minHeight={280} color="#bbf7d0"> <PanelTile wide={2} minHeight={280} color="#bbf7d0">
<Stack spacing={2}> <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"> <Typography variant="body2" color="text.secondary">
Lieferanten der ausgewählten Firma. Lieferanten der ausgewählten Firma.
</Typography> </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 { PureComponent } from 'react';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
@@ -19,6 +21,7 @@ import {
lineFromApi, lineFromApi,
manifestConfig, manifestConfig,
TextEditorDialog, TextEditorDialog,
ArticleSearchDialog,
} from '../../components/manifest'; } from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog'; import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig'; import { loadParties } from '../../components/manifest/manifestConfig';
@@ -28,7 +31,9 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail'; import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText'; 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 { class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -36,9 +41,10 @@ class LieferungNeuPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.cfg = manifestConfig('delivery'); this.cfg = manifestConfig('delivery');
this.automat = getDeliveryEditorAutomat(props.docId);
this.state = { this.state = {
...deliveryEditorAutomat.state, ...this.automat.state,
status: 'loadingParties', status: props.docId != null ? 'loading' : 'loadingParties',
error: null, error: null,
partiesLoading: true, partiesLoading: true,
saving: false, saving: false,
@@ -48,11 +54,16 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '', pdfFileName: '',
textEditorOpen: false, textEditorOpen: false,
textEditorAttachment: null, textEditorAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
}; };
this._cancelled = false; this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this); this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
this.handleDateChange = this.handleDateChange.bind(this); this.handleDateChange = this.handleDateChange.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this); this.handleNumberChange = this.handleNumberChange.bind(this);
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this); this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
this.handleLineChange = this.handleLineChange.bind(this); this.handleLineChange = this.handleLineChange.bind(this);
@@ -63,41 +74,58 @@ class LieferungNeuPage extends PureComponent {
this.handlePdfRescan = this.handlePdfRescan.bind(this); this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this); this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this); this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this); this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.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.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleCreateArticleForLine = this.handleCreateArticleForLine.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() { componentDidMount() {
this.unsubscribe = deliveryEditorAutomat.subscribe(this); this.unsubscribe = this.automat.subscribe(this);
this._cancelled = false; this._cancelled = false;
if (this.props.docId != null) { if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
void this.loadEdit(); void this.loadEdit();
} else { } else {
setLastDeliveryPath('/lieferung/neu');
void this.loadPartiesOnly(); void this.loadPartiesOnly();
if (this.props.initialDelivery) { if (this.props.initialDelivery) {
deliveryEditorAutomat.actions.setDelivery(this.props.initialDelivery); this.automat.actions.setDelivery(this.props.initialDelivery);
} else {
deliveryEditorAutomat.actions.resetDelivery();
} }
} }
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.docId !== this.props.docId) { 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) { if (this.props.docId != null) {
void this.loadEdit(); void this.loadEdit();
} else { } else {
void this.loadPartiesOnly(); void this.loadPartiesOnly();
deliveryEditorAutomat.actions.resetDelivery(); if (this.props.initialDelivery) {
this.automat.actions.setDelivery(this.props.initialDelivery);
}
} }
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
if (this._skuDebounceTimers) { if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout); Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null; this._skuDebounceTimers = null;
@@ -110,7 +138,7 @@ class LieferungNeuPage extends PureComponent {
try { try {
const parties = await loadParties('delivery', this.context); const parties = await loadParties('delivery', this.context);
if (this._cancelled) return; if (this._cancelled) return;
deliveryEditorAutomat.actions.setDeliveryEntities(parties); this.automat.actions.setDeliveryEntities(parties);
this.setState({ this.setState({
status: 'ready', status: 'ready',
partiesLoading: false, partiesLoading: false,
@@ -145,7 +173,7 @@ class LieferungNeuPage extends PureComponent {
]); ]);
if (this._cancelled) return; if (this._cancelled) return;
deliveryEditorAutomat.actions.setDeliveryEntities(parties); this.automat.actions.setDeliveryEntities(parties);
requireObject(docRes, cfg.getPathLabel); requireObject(docRes, cfg.getPathLabel);
requireObject(docRes[cfg.docField], cfg.docField); requireObject(docRes[cfg.docField], cfg.docField);
@@ -154,17 +182,17 @@ class LieferungNeuPage extends PureComponent {
const lines = const lines =
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
deliveryEditorAutomat.actions.setId(doc.id ?? docId); this.automat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date)); this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]); if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number); if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines); this.automat.actions.setLineitems(lines);
// Restore attachments with thumbnails // Restore attachments with thumbnails
const apiFiles = doc.files ?? []; const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments); const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
deliveryEditorAutomat.actions.setAttachments(attachments); this.automat.actions.setAttachments(attachments);
this.setState({ this.setState({
status: 'ready', status: 'ready',
@@ -186,19 +214,31 @@ class LieferungNeuPage extends PureComponent {
} }
handlePartyChange(partyId) { 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) { handleDateChange(date) {
deliveryEditorAutomat.actions.setDate(date); this.automat.actions.setDate(date);
} }
handleNumberChange(number) { handleNumberChange(number) {
deliveryEditorAutomat.actions.setNumber(number); this.automat.actions.setNumber(number);
} }
handleExternalNumberChange(externalNumber) { handleExternalNumberChange(externalNumber) {
deliveryEditorAutomat.actions.setExternalNumber(externalNumber); this.automat.actions.setExternalNumber(externalNumber);
} }
findLineIndex(key) { findLineIndex(key) {
@@ -214,7 +254,7 @@ class LieferungNeuPage extends PureComponent {
handleLineChange(key, field, value) { handleLineChange(key, field, value) {
const index = this.findLineIndex(key); const index = this.findLineIndex(key);
deliveryEditorAutomat.actions.updateLineitem({ this.automat.actions.updateLineitem({
index, index,
key, key,
[field]: value, [field]: value,
@@ -229,7 +269,7 @@ class LieferungNeuPage extends PureComponent {
} }
this._skuDebounceTimers[key] = setTimeout(() => { this._skuDebounceTimers[key] = setTimeout(() => {
if (this._skuDebounceTimers) delete this._skuDebounceTimers[key]; if (this._skuDebounceTimers) delete this._skuDebounceTimers[key];
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key); this.automat.actions.fetchSupplierArticleForSku(value, key);
}, 350); }, 350);
} }
} }
@@ -239,7 +279,7 @@ class LieferungNeuPage extends PureComponent {
clearTimeout(this._skuDebounceTimers[key]); clearTimeout(this._skuDebounceTimers[key]);
delete this._skuDebounceTimers[key]; delete this._skuDebounceTimers[key];
} }
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key); this.automat.actions.fetchSupplierArticleForSku(value, key);
} }
async handleCreateArticleForLine(line) { async handleCreateArticleForLine(line) {
@@ -294,7 +334,7 @@ class LieferungNeuPage extends PureComponent {
// Keep the supplier SKU in the delivery line, but refresh supplier article data // Keep the supplier SKU in the delivery line, but refresh supplier article data
// using the supplier SKU (not the internal SKU) // 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' }); enqueueSnackbar(`Artikel "${internalSku}" erstellt und Lieferanten-Artikel "${supplierSku}" zugeordnet.`, { variant: 'success' });
} catch (err) { } 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() { handleAddLine() {
deliveryEditorAutomat.actions.addLineitem(); this.automat.actions.addLineitem();
} }
handleRemoveLine(key) { handleRemoveLine(key) {
const index = this.findLineIndex(key); const index = this.findLineIndex(key);
deliveryEditorAutomat.actions.removeLineitem(index >= 0 ? index : key); this.automat.actions.removeLineitem(index >= 0 ? index : key);
} }
async handlePdfUpload(file) { async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return; if (!file || this.state.pdfUploading) return;
// 1. Generate thumbnail for PDF const isPdf =
let thumbnail = null; file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
try { const isCsv =
thumbnail = await generatePdfThumbnail(file); file.type === 'text/csv' || file.name?.toLowerCase().endsWith('.csv');
} catch (err) {
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
}
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; if (isPdf) {
const pdfAttachment = { // 1. Generate thumbnail for PDF
id: pdfId, let thumbnail = null;
file, try {
name: file.name, thumbnail = await generatePdfThumbnail(file);
size: file.size, } catch (err) {
type: 'application/pdf', console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
thumbnail, }
uploadedAt: new Date().toISOString(),
};
deliveryEditorAutomat.actions.addAttachment(pdfAttachment);
// 2. Extract text using pdftotext-wasm to create 2nd file (textfile) const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try { const pdfAttachment = {
const text = await extractPdfText(file, { layout: true }); id: pdfId,
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt'; file,
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); name: file.name,
const txtAttachment = { size: file.size,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: 'application/pdf',
file: txtFile, thumbnail,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}; };
deliveryEditorAutomat.actions.addAttachment(txtAttachment); this.automat.actions.addAttachment(pdfAttachment);
} catch (txtErr) {
console.error('Fehler beim Extrahieren von Text mit pdftotext-wasm:', txtErr); // 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 // 3. Run SSE parsing with backend (POST /api/delivery/parse)
await this.runPdfParse(file); await this.runParse(file);
} }
async handlePdfRescan(attachment) { 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 // 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) { if (isPdf) {
try { 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 txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); 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, (a) => a.parentPdfId === attachment.id || a.name === txtName,
); );
if (existingTxt) { if (existingTxt) {
deliveryEditorAutomat.actions.updateAttachment({ this.automat.actions.updateAttachment({
id: existingTxt.id, id: existingTxt.id,
file: txtFile, file: txtFile,
size: txtFile.size, size: txtFile.size,
@@ -382,7 +539,7 @@ class LieferungNeuPage extends PureComponent {
isDirty: true, isDirty: true,
}); });
} else { } else {
deliveryEditorAutomat.actions.addAttachment({ this.automat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile, file: txtFile,
name: txtFile.name, name: txtFile.name,
@@ -403,19 +560,25 @@ class LieferungNeuPage extends PureComponent {
handlePdfDelete(attachment) { handlePdfDelete(attachment) {
if (!attachment?.id) return; if (!attachment?.id) return;
deliveryEditorAutomat.actions.removeAttachment(attachment.id); this.automat.actions.removeAttachment(attachment.id);
} }
async runParse(file) { async runParse(file) {
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({ this.setState({
pdfUploading: true, pdfUploading: true,
pdfDialogOpen: true, pdfDialogOpen: true,
pdfFileName: file.name, pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null }, pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
}); });
const appendMessage = (msg) => { const appendMessage = (msg) => {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
...prev.pdfDialogData, ...prev.pdfDialogData,
@@ -428,8 +591,8 @@ class LieferungNeuPage extends PureComponent {
const parser = this.context?.delivery?.parseSse; const parser = this.context?.delivery?.parseSse;
if (!parser) throw new Error('parseSse nicht verfügbar.'); if (!parser) throw new Error('parseSse nicht verfügbar.');
for await (const { event, data } of parser(file)) { for await (const { event, data } of parser(file, { signal: abortController.signal })) {
if (this._cancelled) return; if (this._cancelled || abortController.signal.aborted) return;
switch (event) { switch (event) {
case 'status': case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) }); appendMessage({ type: 'status', text: data.message ?? String(data) });
@@ -487,9 +650,17 @@ class LieferungNeuPage extends PureComponent {
const docType = result?.type ?? result?.basedata?.manifestType; const docType = result?.type ?? result?.basedata?.manifestType;
const notes = Array.isArray(result?.notes) ? result.notes : []; 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 is null, try matching supplier by name from loaded delivery entities
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.state.deliveryEntities)) { if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = result.basedata.lieferantName.trim().toLowerCase(); const nameToMatch = parsedSupplierName.toLowerCase();
const matched = this.state.deliveryEntities.find( const matched = this.state.deliveryEntities.find(
(e) => (e) =>
e.name && e.name &&
@@ -500,24 +671,29 @@ class LieferungNeuPage extends PureComponent {
if (matched) supplierId = matched.id; if (matched) supplierId = matched.id;
} }
if (docDate) deliveryEditorAutomat.actions.setDate(docDate); const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
if (supplierId != null) deliveryEditorAutomat.actions.setDeliveryEntity(supplierId); 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) { 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) { if (newLines && newLines.length > 0) {
deliveryEditorAutomat.actions.setLineitems(newLines); this.automat.actions.setLineitems(newLines);
} }
if (notes.length > 0) { if (notes.length > 0) {
deliveryEditorAutomat.actions.setNotes(notes); this.automat.actions.setNotes(notes);
} }
// Supply whole article list to GET /supplier/{id}/articles // Supply whole article list to GET /supplier/{id}/articles
const effectiveSupplierId = supplierId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId; const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : deliveryEditorAutomat.state.lines; const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) { if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
deliveryEditorAutomat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines); this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
} }
break; break;
} }
@@ -536,6 +712,17 @@ class LieferungNeuPage extends PureComponent {
} }
} catch (err) { } catch (err) {
if (this._cancelled) return; 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) => ({ this.setState((prev) => ({
pdfDialogData: { pdfDialogData: {
...prev.pdfDialogData, ...prev.pdfDialogData,
@@ -544,6 +731,9 @@ class LieferungNeuPage extends PureComponent {
}, },
})); }));
} finally { } finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) { if (!this._cancelled) {
this.setState((prev) => ({ this.setState((prev) => ({
pdfUploading: false, pdfUploading: false,
@@ -563,28 +753,157 @@ class LieferungNeuPage extends PureComponent {
await this.runParse(file); 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() { 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) { handleAttachmentClick(attachment) {
if (!attachment) return; if (!attachment) return;
const isPdf =
attachment.type === 'application/pdf' ||
attachment.name?.toLowerCase().endsWith('.pdf');
const isText = const isText =
attachment.type?.startsWith('text/') || !isPdf &&
attachment.name?.toLowerCase().endsWith('.txt') || (attachment.type?.startsWith('text/') ||
Boolean(attachment.text); attachment.name?.toLowerCase().endsWith('.txt') ||
attachment.name?.toLowerCase().endsWith('.csv'));
if (isText) { if (isText) {
this.setState({ this.setState({
textEditorOpen: true, textEditorOpen: true,
textEditorAttachment: attachment, 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) { if (attachment.file.size > 0) {
const url = URL.createObjectURL(attachment.file); const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank'); 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() { handleCloseTextEditor() {
@@ -602,7 +921,7 @@ class LieferungNeuPage extends PureComponent {
type: 'text/plain;charset=utf-8', type: 'text/plain;charset=utf-8',
}); });
deliveryEditorAutomat.actions.updateAttachment({ this.automat.actions.updateAttachment({
id: attachment.id, id: attachment.id,
file: newFile, file: newFile,
size: newFile.size, size: newFile.size,
@@ -654,7 +973,7 @@ class LieferungNeuPage extends PureComponent {
await this.context[cfg.updateCache](); await this.context[cfg.updateCache]();
if (this._cancelled) return; if (this._cancelled) return;
enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
deliveryEditorAutomat.actions.resetDelivery(); this.automat.actions.resetDelivery();
this.props.navigate(cfg.editPath(data[cfg.docField].id)); this.props.navigate(cfg.editPath(data[cfg.docField].id));
} else { } else {
const data = await this.context[cfg.api].update(docId, body); 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 apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
deliveryEditorAutomat.actions.setId(doc.id ?? docId); this.automat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date)); this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]); if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number); if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines); this.automat.actions.setLineitems(lines);
// Restore attachments with thumbnails // Restore attachments with thumbnails
const apiFiles = doc.files ?? []; const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments); const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
deliveryEditorAutomat.actions.setAttachments(attachments); this.automat.actions.setAttachments(attachments);
enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
this.setState({ saving: false }); this.setState({ saving: false });
@@ -750,16 +1069,50 @@ class LieferungNeuPage extends PureComponent {
/> />
{partyId == null && (!lines || lines.length === 0) ? ( {partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0"> <PanelTile wide={3} minHeight={160} color="#e2e8f0">
<Typography color="text.secondary"> {this.state.unmatchedSupplierName ? (
{cfg.partyGateMessage} <Stack spacing={2} sx={{ p: 1 }}>
</Typography> <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> </PanelTile>
) : ( ) : (
<> <>
{partyId == null && lines && lines.length > 0 && ( {partyId == null && (
<PanelTile wide={3} color="#fef3c7"> <PanelTile wide={3} color="#fef3c7">
<Alert severity="warning" sx={{ py: 0.5 }}> <Alert
Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können. 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> </Alert>
</PanelTile> </PanelTile>
)} )}
@@ -778,6 +1131,9 @@ class LieferungNeuPage extends PureComponent {
onRemoveLine={this.handleRemoveLine} onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()} onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine} onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
/> />
</> </>
)} )}
@@ -800,13 +1156,25 @@ class LieferungNeuPage extends PureComponent {
open={pdfDialogOpen} open={pdfDialogOpen}
filename={pdfFileName} filename={pdfFileName}
data={pdfDialogData} data={pdfDialogData}
unmatchedSupplierName={this.state.unmatchedSupplierName}
onCreateSupplier={this.handleCreateSupplierFromPdf}
onClose={this.handleClosePdfDialog} onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/> />
<TextEditorDialog <TextEditorDialog
open={this.state.textEditorOpen} open={this.state.textEditorOpen}
docId={this.props.docId ?? this.state.id}
attachment={this.state.textEditorAttachment} attachment={this.state.textEditorAttachment}
onClose={this.handleCloseTextEditor} onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor} 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 { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { brand as brandApi } from '../api'; 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 { requireArray, requireObject } from '../utils/apiShape';
import { catchBackendError } from '../utils/backendError'; import { catchBackendError } from '../utils/backendError';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
function sortBrands(brands) { function sortBrands(brands) {
return [...brands].sort((a, b) => return [...brands].sort((a, b) =>
@@ -71,9 +69,8 @@ const brandSlice = createSlice({
row, row,
]); ]);
}) })
.addCase(selectCompany.fulfilled, () => initialState) .addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(createCompany.fulfilled, () => initialState) .addCase(AUTH_LOGGED_OUT, () => initialState);
.addCase(logout.fulfilled, () => initialState);
}, },
}); });
@@ -84,12 +81,9 @@ export const selectBrandsError = (state) => state.brand.error;
export function brandLoadListener(listenerMiddleware) { export function brandLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({ listenerMiddleware.startListening({
matcher: (action) => matcher: (action) =>
bootstrapSession.fulfilled.match(action) || action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
effect: async (_action, api) => { effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId; const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) { if (companyId) {
api.dispatch(loadBrands()); api.dispatch(loadBrands());
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,22 @@
import * as pdfjsLib from 'pdfjs-dist'; import { pdfToPng } from 'pdfconvert-wasm';
// 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();
export async function generatePdfThumbnail(pdfSource, targetWidth = 200) { export async function generatePdfThumbnail(pdfSource, targetWidth = 200) {
let loadingTask; const images = await pdfToPng(pdfSource, {
if (pdfSource instanceof File || pdfSource instanceof Blob) { firstPage: 1,
const arrayBuffer = await pdfSource.arrayBuffer(); singleFile: true,
loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); scaleToX: targetWidth,
} else { });
loadingTask = pdfjsLib.getDocument(pdfSource);
if (!images || images.length === 0) {
return null;
} }
const pdf = await loadingTask.promise; const blob = new Blob([images[0].data], { type: 'image/png' });
const page = await pdf.getPage(1); return new Promise((resolve, reject) => {
const reader = new FileReader();
const unscaledViewport = page.getViewport({ scale: 1 }); reader.onloadend = () => resolve(reader.result);
const scale = targetWidth / unscaledViewport.width; reader.onerror = reject;
const viewport = page.getViewport({ scale }); reader.readAsDataURL(blob);
});
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);
} }