Compare commits
10 Commits
dd13f736c0
...
2046f96768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2046f96768 | ||
|
|
98080f0514 | ||
|
|
1b6e3ec7e2 | ||
|
|
366d2517d6 | ||
|
|
5594c59d88 | ||
|
|
4011ebfc61 | ||
|
|
d3b4743b66 | ||
|
|
abe078ff05 | ||
|
|
02e9a555d6 | ||
|
|
c0082cea65 |
208
.agents/skills/automat/SKILL.md
Normal file
208
.agents/skills/automat/SKILL.md
Normal 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.
|
||||
|
||||
[](https://www.npmjs.com/package/react-automat)
|
||||
[](https://bundlephobia.com/package/react-automat)
|
||||
[](https://sebgreen.net/automat/)
|
||||
[](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
543
package-lock.json
generated
@@ -16,12 +16,11 @@
|
||||
"@mui/material": "^9.4.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"automat": "file:../automat",
|
||||
"dashgui": "file:../dashgui",
|
||||
"notistack": "^3.0.2",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"pdftotext-wasm": "file:../pdftotext-wasm",
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
"react": "^19.3.0",
|
||||
"react-automat": "^0.1.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -35,17 +34,6 @@
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"../automat": {
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"../dashgui": {
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
@@ -68,13 +56,6 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"../pdftotext-wasm": {
|
||||
"version": "0.1.0",
|
||||
"license": "GPLv3",
|
||||
"bin": {
|
||||
"pdftotext-wasm": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
|
||||
@@ -442,9 +423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"version": "7.29.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
|
||||
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
@@ -2091,271 +2072,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
|
||||
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
|
||||
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
|
||||
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -2377,9 +2093,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||
"version": "0.150.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2423,9 +2139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
||||
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2440,9 +2156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2457,9 +2173,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2474,9 +2190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
|
||||
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2491,9 +2207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
||||
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2508,9 +2224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
|
||||
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2525,9 +2241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2545,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
|
||||
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2565,9 +2281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2585,9 +2301,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2605,9 +2321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2625,9 +2341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
|
||||
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2645,9 +2361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2662,9 +2378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
|
||||
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2679,9 +2395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
|
||||
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3455,10 +3171,6 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/automat": {
|
||||
"resolved": "../automat",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -3543,9 +3255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.23",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||
"integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
|
||||
"version": "2.11.25",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
|
||||
"integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -3569,9 +3281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.9",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||
"version": "4.29.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
|
||||
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3589,11 +3301,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"baseline-browser-mapping": "^2.11.23",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"electron-to-chromium": "^1.5.420",
|
||||
"node-releases": "^2.0.54",
|
||||
"update-browserslist-db": "^1.3.2"
|
||||
"electron-to-chromium": "^1.5.427",
|
||||
"node-releases": "^2.0.55",
|
||||
"update-browserslist-db": "^1.3.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -4039,9 +3751,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.428",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz",
|
||||
"integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==",
|
||||
"version": "1.5.433",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz",
|
||||
"integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -4285,9 +3997,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4888,12 +4600,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
|
||||
"integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
"hasown": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5745,9 +5457,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.55",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
|
||||
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
|
||||
"version": "2.0.56",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
|
||||
"integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5977,9 +5689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -5995,21 +5707,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.3.289",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
|
||||
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pdftotext-wasm": {
|
||||
"resolved": "../pdftotext-wasm",
|
||||
"link": true
|
||||
"node_modules/pdfconvert-wasm": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pdfconvert-wasm/-/pdfconvert-wasm-0.3.0.tgz",
|
||||
"integrity": "sha512-FbEk+Grg0UST4kOPuN7OFfVsut1FvDOJNPS3pt4rzCS8VlFsFzD7ptg7YtFZjEOKgJ4MgqlfCgjK4RYaRBpT0Q==",
|
||||
"license": "GPLv3"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
@@ -6136,6 +5838,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-automat": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-automat/-/react-automat-0.1.0.tgz",
|
||||
"integrity": "sha512-/lGKX2gXyBo52rRc6qC0ZGeThuD2C/LNjJWCChA/WC3JCJ4anyx7xCREfNmMOgRUBE0k4+LF0EbvzJwb4/RxfQ==",
|
||||
"license": "0BSD",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
@@ -6336,9 +6052,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regjsparser": {
|
||||
"version": "0.13.2",
|
||||
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz",
|
||||
"integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==",
|
||||
"version": "0.13.3",
|
||||
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz",
|
||||
"integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
@@ -6412,13 +6128,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
||||
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.149.0",
|
||||
"@oxc-project/types": "=0.150.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -6428,21 +6144,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||
"@rolldown/binding-android-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.9",
|
||||
"@rolldown/binding-android-arm64": "1.2.9",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.9",
|
||||
"@rolldown/binding-darwin-x64": "1.2.9",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.9",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.9",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.9",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.9",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -7095,19 +6811,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-byte-offset": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
|
||||
"integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz",
|
||||
"integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"for-each": "^0.3.3",
|
||||
"call-bind": "^1.0.9",
|
||||
"for-each": "^0.3.5",
|
||||
"gopd": "^1.2.0",
|
||||
"has-proto": "^1.2.0",
|
||||
"is-typed-array": "^1.1.15",
|
||||
"reflect.getprototypeof": "^1.0.9"
|
||||
"reflect.getprototypeof": "^1.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -7480,9 +7195,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.22",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||
"integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
|
||||
"version": "1.1.24",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz",
|
||||
"integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
"@mui/material": "^9.4.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"automat": "file:../automat",
|
||||
"dashgui": "file:../dashgui",
|
||||
"notistack": "^3.0.2",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"pdftotext-wasm": "file:../pdftotext-wasm",
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
"react": "^19.3.0",
|
||||
"react-automat": "^0.1.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
|
||||
31
src/App.jsx
31
src/App.jsx
@@ -1,9 +1,8 @@
|
||||
import { Component, Suspense } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { PureComponent, Suspense } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice';
|
||||
import authAutomat from './automats/authAutomat';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import CompanySetupDialog from './components/CompanySetupDialog';
|
||||
import { router } from './appRouter';
|
||||
@@ -24,9 +23,22 @@ function PageFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
class App extends Component {
|
||||
class App extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = authAutomat.state;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, needsCompanySetup } = this.props;
|
||||
const { status, user } = this.state;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -48,7 +60,7 @@ class App extends Component {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (needsCompanySetup) {
|
||||
if (user?.selectedCompanyId == null) {
|
||||
return <CompanySetupDialog />;
|
||||
}
|
||||
|
||||
@@ -60,9 +72,4 @@ class App extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
status: selectAuthStatus(state),
|
||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(App);
|
||||
export default App;
|
||||
|
||||
@@ -105,19 +105,25 @@ export class ApiClient {
|
||||
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
||||
/** DELETE /api/delivery/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
||||
/** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */
|
||||
getFile: (id, fileId, { download = false } = {}) =>
|
||||
this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, {
|
||||
query: download ? { download: 'true' } : undefined,
|
||||
}),
|
||||
/**
|
||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
||||
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename
|
||||
* Returns an async generator that yields SSE event objects:
|
||||
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
||||
*/
|
||||
parseSse: async function* (file) {
|
||||
parseSse: async function* (file, { signal } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
@@ -133,25 +139,56 @@ export class ApiClient {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // keep incomplete last line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
// blank line or comment: reset
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // keep incomplete last line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
// blank line or comment: reset
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
parsePdfSse(file, opts) {
|
||||
return this.parseSse(file, opts);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { parseCategoryTreeResponse } from '../store/categorySlice';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import articleAutomat from '../store/articleAutomat';
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||
@@ -102,6 +103,10 @@ export class StorageManager extends ApiClient {
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
this.#articlesBySku.set(key, article);
|
||||
|
||||
// Update automat as well
|
||||
articleAutomat.actions.setArticle(article);
|
||||
|
||||
const watchers = this.#articleWatchers.get(key);
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(article);
|
||||
@@ -329,6 +334,10 @@ export class StorageManager extends ApiClient {
|
||||
|
||||
#onArticleSignal = (event) => {
|
||||
const sku = event.type.slice('article-'.length);
|
||||
|
||||
// Update automat on SSE article events
|
||||
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
|
||||
|
||||
if (this.#articleWatchers.has(sku)) {
|
||||
void this.loadArticle(sku).catch((err) => notifyError(err));
|
||||
}
|
||||
@@ -373,7 +382,6 @@ export class StorageManager extends ApiClient {
|
||||
const companyCache = createCache('GET /company', () => this.#loadCompanies());
|
||||
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
|
||||
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
|
||||
const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
|
||||
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
|
||||
const customergroupCache = createCache('GET /customergroup', () =>
|
||||
this.#loadCustomergroups(),
|
||||
@@ -415,11 +423,6 @@ export class StorageManager extends ApiClient {
|
||||
posterminalPromise: { get: () => posterminalCache.promise },
|
||||
updatePosterminal: { value: posterminalCache.update },
|
||||
|
||||
supplierPromise: { get: () => supplierCache.promise },
|
||||
updateSupplier: { value: supplierCache.update },
|
||||
/** Settled GET /supplier list, or undefined. */
|
||||
suppliers: { get: () => supplierCache.value },
|
||||
|
||||
deliveryPromise: { get: () => deliveryCache.promise },
|
||||
updateDelivery: { value: deliveryCache.update },
|
||||
/** Settled GET /delivery list, or undefined. */
|
||||
@@ -571,12 +574,6 @@ export class StorageManager extends ApiClient {
|
||||
return requireArray(data.posterminals, 'posterminals');
|
||||
}
|
||||
|
||||
async #loadSuppliers() {
|
||||
const data = await this.supplier.list();
|
||||
requireObject(data, 'GET /api/supplier');
|
||||
return requireArray(data.suppliers, 'suppliers');
|
||||
}
|
||||
|
||||
async #loadDeliveries() {
|
||||
const data = await this.delivery.list();
|
||||
requireObject(data, 'GET /api/delivery');
|
||||
|
||||
@@ -682,6 +682,104 @@ export const delivery = {
|
||||
return http(`/api/delivery/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/delivery/:id/file/:fileId → Blob
|
||||
* Full binary content of attached file (PDF, text, etc.)
|
||||
*/
|
||||
async getFile(id, fileId, { download = false } = {}) {
|
||||
const query = download ? '?download=true' : '';
|
||||
const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
||||
* Accepts PDF, TXT or CSV document (max 50 MB)
|
||||
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
|
||||
*/
|
||||
async *parseSse(file, { signal } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createContext, createElement, useContext, useRef } from 'react';
|
||||
import { StorageManager } from './StorageManager';
|
||||
|
||||
export const StorageManagerContext = createContext(null);
|
||||
let defaultStorageManager = null;
|
||||
|
||||
export function getStorageManager() {
|
||||
if (!defaultStorageManager) {
|
||||
defaultStorageManager = new StorageManager();
|
||||
}
|
||||
return defaultStorageManager;
|
||||
}
|
||||
|
||||
export const StorageManagerContext = createContext(getStorageManager());
|
||||
|
||||
/**
|
||||
* Provider — mount once near the top of the tree.
|
||||
@@ -9,7 +18,7 @@ export const StorageManagerContext = createContext(null);
|
||||
export function StorageManagerProvider({ children }) {
|
||||
const mgrRef = useRef(null);
|
||||
if (!mgrRef.current) {
|
||||
mgrRef.current = new StorageManager();
|
||||
mgrRef.current = getStorageManager();
|
||||
}
|
||||
|
||||
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
|
||||
@@ -19,9 +28,6 @@ export function StorageManagerProvider({ children }) {
|
||||
* Hook for function components that need the store manager.
|
||||
*/
|
||||
export function useStorageManager() {
|
||||
const ctx = useContext(StorageManagerContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useStorageManager must be used within a <StorageManagerProvider>');
|
||||
}
|
||||
const ctx = useContext(StorageManagerContext) || getStorageManager();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
180
src/automats/authAutomat.js
Normal file
180
src/automats/authAutomat.js
Normal 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;
|
||||
@@ -1,6 +1,59 @@
|
||||
import { Automat } from 'automat';
|
||||
import { supplier as supplierApi } from '../api';
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi, supplier as supplierApi } from '../api';
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeliveryLine
|
||||
* @property {string|number} key Stable editor key.
|
||||
* @property {string|null} name Article name.
|
||||
* @property {string|number|null} sku Supplier or internal article SKU.
|
||||
* @property {number|null} amount Ordered amount.
|
||||
* @property {number|null} pu Price unit.
|
||||
* @property {string|null} unit Unit of measure.
|
||||
* @property {number|null} netprice Net price.
|
||||
* @property {number|null} taxrate Tax rate.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SupplierArticle
|
||||
* @property {string|number} [id] Supplier-article identifier.
|
||||
* @property {string|number|null} [sku] Supplier SKU.
|
||||
* @property {{sku?: string|number|null, eans?: Array<string|number>, articleean?: Array<string|number>}} [article]
|
||||
* Linked internal article.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeliveryEditorState
|
||||
* @property {Array<Object>} deliveryEntities Available suppliers or delivery parties.
|
||||
* @property {string|number|null} id Delivery identifier.
|
||||
* @property {string|number|null} deliveryId Alias of `id`.
|
||||
* @property {string|null} date Delivery date.
|
||||
* @property {string|number|null} deliveryEntity Selected delivery party.
|
||||
* @property {string|number|null} partyId Alias of `deliveryEntity`.
|
||||
* @property {string|null} externalNumber External delivery number.
|
||||
* @property {string|null} external_number API-compatible alias of `externalNumber`.
|
||||
* @property {string|null} number Internal delivery number.
|
||||
* @property {string|null} type Delivery type.
|
||||
* @property {Array<DeliveryLine>} lines Editable lines.
|
||||
* @property {Array<DeliveryLine>} lineitems Alias of `lines`.
|
||||
* @property {Array<*>} notes Delivery notes.
|
||||
* @property {Array<Object>} attachments Delivery attachments.
|
||||
* @property {Array<Object>} files Alias of `attachments`.
|
||||
* @property {Array<SupplierArticle>} supplierArticles Loaded supplier articles.
|
||||
* @property {Object<string, SupplierArticle>} supplierArticlesMap Articles indexed by SKU or EAN.
|
||||
* @property {Object<string, SupplierArticle|null>} supplierArticlesByLineKey Matched article per line.
|
||||
* @property {boolean} supplierArticlesLoading Whether any supplier article request is pending.
|
||||
* @property {Object<string, boolean>} loadingLineKeys Pending lookup state per line.
|
||||
* @property {Object<string, boolean>} busySupplierArticleLineKeys Lines with a pending unlink request.
|
||||
* @property {string|null} supplierArticlesError Last supplier article error.
|
||||
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes a partial line into an editor line with a stable key.
|
||||
*
|
||||
* @param {Partial<DeliveryLine>} [line] Initial line values.
|
||||
* @returns {DeliveryLine} Normalized line.
|
||||
*/
|
||||
export function createEmptyLineitem(line = {}) {
|
||||
return {
|
||||
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -14,6 +67,14 @@ export function createEmptyLineitem(line = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes supplier articles by supplier SKU, internal SKU, and EAN. Text keys
|
||||
* are stored both verbatim and lower-cased for case-insensitive lookup.
|
||||
*
|
||||
* @param {Array<SupplierArticle>} [articles] Articles to index.
|
||||
* @param {Object<string, SupplierArticle>} [existingMap] Existing index to extend.
|
||||
* @returns {Object<string, SupplierArticle>} Article lookup map.
|
||||
*/
|
||||
export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
||||
const map = { ...existingMap };
|
||||
for (const item of articles) {
|
||||
@@ -34,7 +95,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
||||
map[artSku.toLowerCase()] = item;
|
||||
}
|
||||
}
|
||||
// Map by EANs
|
||||
// Map by Eans
|
||||
const eans = item.article?.eans ?? item.article?.articleean ?? [];
|
||||
for (const ean of eans) {
|
||||
if (ean != null) {
|
||||
@@ -49,6 +110,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** @type {Omit<DeliveryEditorState, 'deliveryEntities'>} */
|
||||
export const initialDelivery = {
|
||||
id: null,
|
||||
deliveryId: null,
|
||||
@@ -69,344 +131,513 @@ export const initialDelivery = {
|
||||
supplierArticlesByLineKey: {},
|
||||
supplierArticlesLoading: false,
|
||||
loadingLineKeys: {},
|
||||
busySupplierArticleLineKeys: {},
|
||||
supplierArticlesError: null,
|
||||
unmatchedSupplierName: null,
|
||||
};
|
||||
|
||||
|
||||
/** @type {DeliveryEditorState} */
|
||||
export const initialState = {
|
||||
deliveryEntities: [],
|
||||
...initialDelivery,
|
||||
};
|
||||
|
||||
export const deliveryEditorAutomat = new Automat(initialState, {
|
||||
setId(id) {
|
||||
deliveryEditorAutomat.setState({ id, deliveryId: id });
|
||||
},
|
||||
/**
|
||||
* Creates a delivery editor automat or returns the already registered named
|
||||
* instance. Named instances allow independent state for multiple deliveries.
|
||||
*
|
||||
* @param {{name?: string|null}|string} [options] Automat options or name.
|
||||
* @returns {Automat<DeliveryEditorState>} Delivery editor automat.
|
||||
*/
|
||||
export function createDeliveryEditorAutomat(options = {}) {
|
||||
const name = typeof options === 'string' ? options : options?.name ?? null;
|
||||
if (name) {
|
||||
const existing = Automat.get(name);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
setDeliveryEntities(deliveryEntities) {
|
||||
deliveryEditorAutomat.setState({ deliveryEntities: deliveryEntities ?? [] });
|
||||
},
|
||||
let automat;
|
||||
const actions = {
|
||||
/** @param {string|number|null} id @returns {void} */
|
||||
setId(id) {
|
||||
automat.setState({ id, deliveryId: id });
|
||||
},
|
||||
|
||||
setDelivery(delivery = {}) {
|
||||
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
|
||||
const lines = Array.isArray(rawLines) ? rawLines : [];
|
||||
const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null;
|
||||
const extNum = delivery.externalNumber ?? delivery.external_number ?? null;
|
||||
const id = delivery.id ?? delivery.deliveryId ?? null;
|
||||
const notes = Array.isArray(delivery.notes)
|
||||
? delivery.notes
|
||||
: delivery.notes ? [delivery.notes] : [];
|
||||
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
|
||||
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
||||
/** @param {Array<Object>} deliveryEntities @returns {void} */
|
||||
setDeliveryEntities(deliveryEntities) {
|
||||
automat.setState({ deliveryEntities: deliveryEntities ?? [] });
|
||||
},
|
||||
|
||||
deliveryEditorAutomat.setState({
|
||||
...initialDelivery,
|
||||
...delivery,
|
||||
id,
|
||||
deliveryId: id,
|
||||
partyId,
|
||||
deliveryEntity: partyId,
|
||||
externalNumber: extNum,
|
||||
external_number: extNum,
|
||||
lines,
|
||||
lineitems: lines,
|
||||
notes,
|
||||
attachments,
|
||||
files: attachments,
|
||||
});
|
||||
/**
|
||||
* Replaces the edited delivery and normalizes API aliases.
|
||||
* @param {Partial<DeliveryEditorState>} [delivery]
|
||||
* @returns {void}
|
||||
*/
|
||||
setDelivery(delivery = {}) {
|
||||
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
|
||||
const lines = Array.isArray(rawLines) ? rawLines : [];
|
||||
const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null;
|
||||
const extNum = delivery.externalNumber ?? delivery.external_number ?? null;
|
||||
const id = delivery.id ?? delivery.deliveryId ?? null;
|
||||
const notes = Array.isArray(delivery.notes)
|
||||
? delivery.notes
|
||||
: delivery.notes ? [delivery.notes] : [];
|
||||
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
|
||||
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
||||
|
||||
if (partyId && lines.length > 0) {
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticles(partyId, lines);
|
||||
}
|
||||
},
|
||||
automat.setState({
|
||||
...initialDelivery,
|
||||
...delivery,
|
||||
id,
|
||||
deliveryId: id,
|
||||
partyId,
|
||||
deliveryEntity: partyId,
|
||||
externalNumber: extNum,
|
||||
external_number: extNum,
|
||||
lines,
|
||||
lineitems: lines,
|
||||
notes,
|
||||
attachments,
|
||||
files: attachments,
|
||||
});
|
||||
|
||||
setDate(date) {
|
||||
deliveryEditorAutomat.setState({ date });
|
||||
},
|
||||
if (partyId && lines.length > 0) {
|
||||
automat.actions.fetchSupplierArticles(partyId, lines);
|
||||
}
|
||||
},
|
||||
|
||||
setDeliveryEntity(entityId) {
|
||||
const prevId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
|
||||
const idChanged = prevId !== entityId;
|
||||
deliveryEditorAutomat.setState({
|
||||
deliveryEntity: entityId,
|
||||
partyId: entityId,
|
||||
...(idChanged
|
||||
? {
|
||||
supplierArticles: [],
|
||||
supplierArticlesMap: {},
|
||||
supplierArticlesByLineKey: {},
|
||||
loadingLineKeys: {},
|
||||
/** @param {string|null} date @returns {void} */
|
||||
setDate(date) {
|
||||
automat.setState({ date });
|
||||
},
|
||||
|
||||
/**
|
||||
* Selects a delivery party and refreshes its supplier articles.
|
||||
* @param {string|number|null} entityId
|
||||
* @returns {void}
|
||||
*/
|
||||
setDeliveryEntity(entityId) {
|
||||
const prevId = automat.state.deliveryEntity ?? automat.state.partyId;
|
||||
const idChanged = prevId !== entityId;
|
||||
automat.setState({
|
||||
deliveryEntity: entityId,
|
||||
partyId: entityId,
|
||||
unmatchedSupplierName: entityId != null ? null : automat.state.unmatchedSupplierName,
|
||||
...(idChanged
|
||||
? {
|
||||
supplierArticles: [],
|
||||
supplierArticlesMap: {},
|
||||
supplierArticlesByLineKey: {},
|
||||
loadingLineKeys: {},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (entityId && idChanged) {
|
||||
automat.actions.fetchSupplierArticles(entityId);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {string|null} name @returns {void} */
|
||||
setUnmatchedSupplierName(name) {
|
||||
automat.setState({ unmatchedSupplierName: name ?? null });
|
||||
},
|
||||
|
||||
|
||||
/** @param {string|number|null} partyId @returns {void} */
|
||||
setPartyId(partyId) {
|
||||
automat.actions.setDeliveryEntity(partyId);
|
||||
},
|
||||
|
||||
/** @param {string|null} externalNumber @returns {void} */
|
||||
setExternalNumber(externalNumber) {
|
||||
automat.setState({
|
||||
externalNumber,
|
||||
external_number: externalNumber,
|
||||
});
|
||||
},
|
||||
|
||||
/** @param {string|null} number @returns {void} */
|
||||
setNumber(number) {
|
||||
automat.setState({ number });
|
||||
},
|
||||
|
||||
/** @param {string|null} type @returns {void} */
|
||||
setType(type) {
|
||||
automat.setState({ type });
|
||||
},
|
||||
|
||||
/** @param {Array<DeliveryLine>} lines @returns {void} */
|
||||
setLines(lines) {
|
||||
const cleanLines = lines ?? [];
|
||||
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||
},
|
||||
|
||||
/** @param {Array<DeliveryLine>} lines @returns {void} */
|
||||
setLineitems(lines) {
|
||||
const cleanLines = lines ?? [];
|
||||
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||
},
|
||||
|
||||
/** @param {Array<*>|*} notes @returns {void} */
|
||||
setNotes(notes) {
|
||||
const cleanNotes = Array.isArray(notes)
|
||||
? notes
|
||||
: notes ? [notes] : [];
|
||||
automat.setState({ notes: cleanNotes });
|
||||
},
|
||||
|
||||
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
|
||||
addLineitem(line = {}) {
|
||||
const item = createEmptyLineitem(line);
|
||||
const nextLines = [...(automat.state.lines ?? []), item];
|
||||
automat.setState({ lines: nextLines, lineitems: nextLines });
|
||||
},
|
||||
|
||||
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
|
||||
addLine(line = {}) {
|
||||
automat.actions.addLineitem(line);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates a line selected by index or key.
|
||||
* @param {{index?: number, key?: string|number, [key: string]: *}} payload
|
||||
* @returns {void}
|
||||
*/
|
||||
updateLineitem(payload) {
|
||||
const { index, key, ...fields } = payload || {};
|
||||
const currentLines = automat.state.lines ?? [];
|
||||
let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1;
|
||||
if (targetIndex === -1 && key != null) {
|
||||
targetIndex = currentLines.findIndex((l) => l.key === key);
|
||||
}
|
||||
if (targetIndex >= 0 && targetIndex < currentLines.length) {
|
||||
const nextLines = [...currentLines];
|
||||
nextLines[targetIndex] = {
|
||||
...nextLines[targetIndex],
|
||||
...fields,
|
||||
};
|
||||
automat.setState({ lines: nextLines, lineitems: nextLines });
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {{index?: number, key?: string|number, [key: string]: *}} payload @returns {void} */
|
||||
updateLine(payload) {
|
||||
automat.actions.updateLineitem(payload);
|
||||
},
|
||||
|
||||
/** @param {number|string|{key: string|number}} payload @returns {void} */
|
||||
removeLineitem(payload) {
|
||||
const currentLines = automat.state.lines ?? [];
|
||||
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
|
||||
let targetKey = null;
|
||||
if (index === -1 && payload != null) {
|
||||
const key = typeof payload === 'string' ? payload : payload?.key;
|
||||
if (key != null) {
|
||||
index = currentLines.findIndex((l) => l.key === key);
|
||||
targetKey = key;
|
||||
}
|
||||
}
|
||||
if (index >= 0 && index < currentLines.length) {
|
||||
targetKey = targetKey ?? currentLines[index]?.key;
|
||||
const nextLines = currentLines.filter((_, i) => i !== index);
|
||||
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
|
||||
if (targetKey != null) delete byLineKey[targetKey];
|
||||
automat.setState({
|
||||
lines: nextLines,
|
||||
lineitems: nextLines,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {number|string|{key: string|number}} payload @returns {void} */
|
||||
removeLine(payload) {
|
||||
automat.actions.removeLineitem(payload);
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads supplier articles for all current SKUs and rebuilds line matches.
|
||||
* @param {string|number|null} [targetEntityId] Supplier to query.
|
||||
* @param {Array<DeliveryLine>} [targetLines] Lines whose SKUs should be queried.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async fetchSupplierArticles(targetEntityId, targetLines) {
|
||||
const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId;
|
||||
if (!entityId) {
|
||||
automat.setState({
|
||||
supplierArticles: [],
|
||||
supplierArticlesMap: {},
|
||||
supplierArticlesByLineKey: {},
|
||||
supplierArticlesLoading: false,
|
||||
loadingLineKeys: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = targetLines ?? automat.state.lines ?? [];
|
||||
const skus = Array.from(
|
||||
new Set(
|
||||
lines
|
||||
.map((l) => (l.sku != null ? String(l.sku).trim() : ''))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
automat.setState({
|
||||
supplierArticlesLoading: true,
|
||||
supplierArticlesError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await supplierApi.getArticles(entityId, skus);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
|
||||
const map = buildSupplierArticlesMap(articles);
|
||||
const byLineKey = {};
|
||||
for (const line of lines) {
|
||||
if (line.key != null) {
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null;
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (entityId && idChanged) {
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticles(entityId);
|
||||
}
|
||||
},
|
||||
|
||||
setPartyId(partyId) {
|
||||
deliveryEditorAutomat.actions.setDeliveryEntity(partyId);
|
||||
},
|
||||
|
||||
setExternalNumber(externalNumber) {
|
||||
deliveryEditorAutomat.setState({
|
||||
externalNumber,
|
||||
external_number: externalNumber,
|
||||
});
|
||||
},
|
||||
|
||||
setNumber(number) {
|
||||
deliveryEditorAutomat.setState({ number });
|
||||
},
|
||||
|
||||
setType(type) {
|
||||
deliveryEditorAutomat.setState({ type });
|
||||
},
|
||||
|
||||
setLines(lines) {
|
||||
const cleanLines = lines ?? [];
|
||||
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||
},
|
||||
|
||||
setLineitems(lines) {
|
||||
const cleanLines = lines ?? [];
|
||||
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||
},
|
||||
|
||||
setNotes(notes) {
|
||||
const cleanNotes = Array.isArray(notes)
|
||||
? notes
|
||||
: notes ? [notes] : [];
|
||||
deliveryEditorAutomat.setState({ notes: cleanNotes });
|
||||
},
|
||||
|
||||
addLineitem(line = {}) {
|
||||
const item = createEmptyLineitem(line);
|
||||
const nextLines = [...(deliveryEditorAutomat.state.lines ?? []), item];
|
||||
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
|
||||
},
|
||||
|
||||
addLine(line = {}) {
|
||||
deliveryEditorAutomat.actions.addLineitem(line);
|
||||
},
|
||||
|
||||
updateLineitem(payload) {
|
||||
const { index, key, ...fields } = payload || {};
|
||||
const currentLines = deliveryEditorAutomat.state.lines ?? [];
|
||||
let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1;
|
||||
if (targetIndex === -1 && key != null) {
|
||||
targetIndex = currentLines.findIndex((l) => l.key === key);
|
||||
}
|
||||
if (targetIndex >= 0 && targetIndex < currentLines.length) {
|
||||
const nextLines = [...currentLines];
|
||||
nextLines[targetIndex] = {
|
||||
...nextLines[targetIndex],
|
||||
...fields,
|
||||
};
|
||||
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
|
||||
}
|
||||
},
|
||||
|
||||
updateLine(payload) {
|
||||
deliveryEditorAutomat.actions.updateLineitem(payload);
|
||||
},
|
||||
|
||||
removeLineitem(payload) {
|
||||
const currentLines = deliveryEditorAutomat.state.lines ?? [];
|
||||
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
|
||||
let targetKey = null;
|
||||
if (index === -1 && payload != null) {
|
||||
const key = typeof payload === 'string' ? payload : payload?.key;
|
||||
if (key != null) {
|
||||
index = currentLines.findIndex((l) => l.key === key);
|
||||
targetKey = key;
|
||||
automat.setState({
|
||||
supplierArticles: articles,
|
||||
supplierArticlesMap: map,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
supplierArticlesLoading: false,
|
||||
loadingLineKeys: {},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err);
|
||||
automat.setState({
|
||||
supplierArticlesLoading: false,
|
||||
supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (index >= 0 && index < currentLines.length) {
|
||||
targetKey = targetKey ?? currentLines[index]?.key;
|
||||
const nextLines = currentLines.filter((_, i) => i !== index);
|
||||
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) };
|
||||
if (targetKey != null) delete byLineKey[targetKey];
|
||||
deliveryEditorAutomat.setState({
|
||||
lines: nextLines,
|
||||
lineitems: nextLines,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads and matches a supplier article for one line SKU.
|
||||
* @param {string|number|null} sku SKU to query.
|
||||
* @param {string|number} [lineKey] Editor line key receiving the match.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async fetchSupplierArticleForSku(sku, lineKey) {
|
||||
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
|
||||
const trimmed = sku != null ? String(sku).trim() : '';
|
||||
|
||||
if (!entityId || !trimmed) {
|
||||
if (lineKey != null) {
|
||||
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
|
||||
byLineKey[lineKey] = null;
|
||||
automat.setState({ supplierArticlesByLineKey: byLineKey });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prevLoading = automat.state.loadingLineKeys || {};
|
||||
automat.setState({
|
||||
loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeLine(payload) {
|
||||
deliveryEditorAutomat.actions.removeLineitem(payload);
|
||||
},
|
||||
try {
|
||||
const res = await supplierApi.getArticles(entityId, [trimmed]);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
|
||||
async fetchSupplierArticles(targetEntityId, targetLines) {
|
||||
const entityId = targetEntityId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
|
||||
if (!entityId) {
|
||||
deliveryEditorAutomat.setState({
|
||||
supplierArticles: [],
|
||||
supplierArticlesMap: {},
|
||||
supplierArticlesByLineKey: {},
|
||||
supplierArticlesLoading: false,
|
||||
loadingLineKeys: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextMap = buildSupplierArticlesMap(articles, automat.state.supplierArticlesMap);
|
||||
const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null;
|
||||
|
||||
const lines = targetLines ?? deliveryEditorAutomat.state.lines ?? [];
|
||||
const skus = Array.from(
|
||||
new Set(
|
||||
lines
|
||||
.map((l) => (l.sku != null ? String(l.sku).trim() : ''))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
|
||||
if (lineKey != null) {
|
||||
byLineKey[lineKey] = matched;
|
||||
}
|
||||
|
||||
deliveryEditorAutomat.setState({
|
||||
supplierArticlesLoading: true,
|
||||
supplierArticlesError: null,
|
||||
});
|
||||
const currentList = automat.state.supplierArticles || [];
|
||||
const mergedList = [...currentList];
|
||||
for (const a of articles) {
|
||||
if (!mergedList.some((existing) => existing.id === a.id)) {
|
||||
mergedList.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await supplierApi.getArticles(entityId, skus);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
const nextLoading = { ...(automat.state.loadingLineKeys || {}) };
|
||||
if (lineKey != null) delete nextLoading[lineKey];
|
||||
|
||||
const map = buildSupplierArticlesMap(articles);
|
||||
const byLineKey = {};
|
||||
for (const line of lines) {
|
||||
if (line.key != null) {
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
byLineKey[line.key] = rawSku ? (map[rawSku.toLowerCase()] || map[rawSku] || null) : null;
|
||||
automat.setState({
|
||||
supplierArticles: mergedList,
|
||||
supplierArticlesMap: nextMap,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
loadingLineKeys: nextLoading,
|
||||
supplierArticlesLoading: Object.keys(nextLoading).length > 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Abrufen des Artikels für SKU:', err);
|
||||
const nextLoading = { ...(automat.state.loadingLineKeys || {}) };
|
||||
if (lineKey != null) delete nextLoading[lineKey];
|
||||
automat.setState({
|
||||
loadingLineKeys: nextLoading,
|
||||
supplierArticlesLoading: Object.keys(nextLoading).length > 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the supplier-article assignment behind a matched line by
|
||||
* `DELETE /api/article/{sku}/supplier/{id}`, then re-resolves the line.
|
||||
*
|
||||
* @param {string|number} internalSku Internal article SKU.
|
||||
* @param {string|number} assignmentId Article-supplier assignment ID.
|
||||
* @param {string|number} [lineKey] Editor line key showing the match.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async unlinkSupplierArticle(internalSku, assignmentId, lineKey) {
|
||||
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
|
||||
const sku = internalSku != null ? String(internalSku).trim() : '';
|
||||
if (!sku || assignmentId == null) {
|
||||
throw new Error('Artikelsku und Zuordnungs-ID sind erforderlich.');
|
||||
}
|
||||
|
||||
if (lineKey != null) {
|
||||
automat.setState({
|
||||
busySupplierArticleLineKeys: {
|
||||
...(automat.state.busySupplierArticleLineKeys || {}),
|
||||
[lineKey]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await articleApi.deleteSupplier(sku, assignmentId);
|
||||
|
||||
const clearedMap = { ...(automat.state.supplierArticlesMap || {}) };
|
||||
for (const [mapKey, item] of Object.entries(clearedMap)) {
|
||||
if (item != null && String(item.id) === String(assignmentId)) {
|
||||
delete clearedMap[mapKey];
|
||||
}
|
||||
}
|
||||
|
||||
const nextList = (automat.state.supplierArticles || []).filter(
|
||||
(item) => item != null && String(item.id) !== String(assignmentId),
|
||||
);
|
||||
|
||||
const byLineKey = { ...(automat.state.supplierArticlesByLineKey || {}) };
|
||||
if (lineKey != null) byLineKey[lineKey] = null;
|
||||
|
||||
automat.setState({
|
||||
supplierArticles: nextList,
|
||||
supplierArticlesMap: clearedMap,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
});
|
||||
|
||||
if (lineKey != null && entityId) {
|
||||
const line = (automat.state.lines || []).find((l) => l.key === lineKey);
|
||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||
if (rawSku) {
|
||||
await automat.actions.fetchSupplierArticleForSku(rawSku, lineKey);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (lineKey != null) {
|
||||
const nextBusy = { ...(automat.state.busySupplierArticleLineKeys || {}) };
|
||||
delete nextBusy[lineKey];
|
||||
automat.setState({ busySupplierArticleLineKeys: nextBusy });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
deliveryEditorAutomat.setState({
|
||||
supplierArticles: articles,
|
||||
supplierArticlesMap: map,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
supplierArticlesLoading: false,
|
||||
loadingLineKeys: {},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Abrufen der Lieferanten-Artikel:', err);
|
||||
deliveryEditorAutomat.setState({
|
||||
supplierArticlesLoading: false,
|
||||
supplierArticlesError: err?.message || 'Fehler beim Laden der Artikel',
|
||||
});
|
||||
}
|
||||
},
|
||||
/** @param {Object} attachment @returns {void} */
|
||||
addAttachment(attachment) {
|
||||
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const item = { ...attachment, id };
|
||||
const next = [...(automat.state.attachments ?? []), item];
|
||||
automat.setState({ attachments: next, files: next });
|
||||
},
|
||||
|
||||
async fetchSupplierArticleForSku(sku, lineKey) {
|
||||
const entityId = deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
|
||||
const trimmed = sku != null ? String(sku).trim() : '';
|
||||
/** @param {Array<Object>} attachments @returns {void} */
|
||||
setAttachments(attachments) {
|
||||
const clean = Array.isArray(attachments) ? attachments : [];
|
||||
automat.setState({ attachments: clean, files: clean });
|
||||
},
|
||||
|
||||
if (!entityId || !trimmed) {
|
||||
if (lineKey != null) {
|
||||
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) };
|
||||
byLineKey[lineKey] = null;
|
||||
deliveryEditorAutomat.setState({ supplierArticlesByLineKey: byLineKey });
|
||||
/** @param {{id: string|number, [key: string]: *}} payload @returns {void} */
|
||||
updateAttachment(payload) {
|
||||
const { id, ...fields } = payload || {};
|
||||
const current = automat.state.attachments ?? [];
|
||||
const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id));
|
||||
if (idx !== -1) {
|
||||
const next = [...current];
|
||||
next[idx] = { ...next[idx], ...fields };
|
||||
automat.setState({ attachments: next, files: next });
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
const prevLoading = deliveryEditorAutomat.state.loadingLineKeys || {};
|
||||
deliveryEditorAutomat.setState({
|
||||
loadingLineKeys: lineKey != null ? { ...prevLoading, [lineKey]: true } : prevLoading,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await supplierApi.getArticles(entityId, [trimmed]);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
|
||||
const nextMap = buildSupplierArticlesMap(articles, deliveryEditorAutomat.state.supplierArticlesMap);
|
||||
const matched = nextMap[trimmed.toLowerCase()] || nextMap[trimmed] || articles[0] || null;
|
||||
|
||||
const byLineKey = { ...(deliveryEditorAutomat.state.supplierArticlesByLineKey || {}) };
|
||||
if (lineKey != null) {
|
||||
byLineKey[lineKey] = matched;
|
||||
/** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */
|
||||
removeAttachment(idOrPayload) {
|
||||
const current = automat.state.attachments ?? [];
|
||||
const targetId =
|
||||
typeof idOrPayload === 'object' && idOrPayload !== null
|
||||
? (idOrPayload.id ?? idOrPayload.fileId)
|
||||
: idOrPayload;
|
||||
const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId));
|
||||
let next;
|
||||
if (hasMatch) {
|
||||
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
|
||||
} else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) {
|
||||
next = current.filter((_, idx) => idx !== idOrPayload);
|
||||
} else {
|
||||
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
|
||||
}
|
||||
automat.setState({ attachments: next, files: next });
|
||||
},
|
||||
|
||||
const currentList = deliveryEditorAutomat.state.supplierArticles || [];
|
||||
const mergedList = [...currentList];
|
||||
for (const a of articles) {
|
||||
if (!mergedList.some((existing) => existing.id === a.id)) {
|
||||
mergedList.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) };
|
||||
if (lineKey != null) delete nextLoading[lineKey];
|
||||
|
||||
deliveryEditorAutomat.setState({
|
||||
supplierArticles: mergedList,
|
||||
supplierArticlesMap: nextMap,
|
||||
supplierArticlesByLineKey: byLineKey,
|
||||
loadingLineKeys: nextLoading,
|
||||
supplierArticlesLoading: Object.keys(nextLoading).length > 0,
|
||||
/** Resets delivery fields while retaining the available entities. @returns {void} */
|
||||
resetDelivery() {
|
||||
automat.setState({
|
||||
...initialDelivery,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Abrufen des Artikels für SKU:', err);
|
||||
const nextLoading = { ...(deliveryEditorAutomat.state.loadingLineKeys || {}) };
|
||||
if (lineKey != null) delete nextLoading[lineKey];
|
||||
deliveryEditorAutomat.setState({
|
||||
loadingLineKeys: nextLoading,
|
||||
supplierArticlesLoading: Object.keys(nextLoading).length > 0,
|
||||
},
|
||||
|
||||
/** Resets the complete editor state. @returns {void} */
|
||||
resetDeliveryEditor() {
|
||||
automat.setState({
|
||||
...initialState,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
addAttachment(attachment) {
|
||||
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const item = { ...attachment, id };
|
||||
const next = [...(deliveryEditorAutomat.state.attachments ?? []), item];
|
||||
deliveryEditorAutomat.setState({ attachments: next, files: next });
|
||||
},
|
||||
automat = new Automat(initialState, actions, {
|
||||
name,
|
||||
persist: false,
|
||||
});
|
||||
|
||||
setAttachments(attachments) {
|
||||
const clean = Array.isArray(attachments) ? attachments : [];
|
||||
deliveryEditorAutomat.setState({ attachments: clean, files: clean });
|
||||
},
|
||||
return automat;
|
||||
}
|
||||
|
||||
updateAttachment(payload) {
|
||||
const { id, ...fields } = payload || {};
|
||||
const current = deliveryEditorAutomat.state.attachments ?? [];
|
||||
const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id));
|
||||
if (idx !== -1) {
|
||||
const next = [...current];
|
||||
next[idx] = { ...next[idx], ...fields };
|
||||
deliveryEditorAutomat.setState({ attachments: next, files: next });
|
||||
}
|
||||
},
|
||||
/** Shared automat used for new, unsaved deliveries. */
|
||||
export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
|
||||
/** Backward-compatible alias of {@link deliveryDraftAutomat}. */
|
||||
export const deliveryEditorAutomat = deliveryDraftAutomat;
|
||||
|
||||
removeAttachment(idOrPayload) {
|
||||
const current = deliveryEditorAutomat.state.attachments ?? [];
|
||||
const targetId =
|
||||
typeof idOrPayload === 'object' && idOrPayload !== null
|
||||
? (idOrPayload.id ?? idOrPayload.fileId)
|
||||
: idOrPayload;
|
||||
const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId));
|
||||
let next;
|
||||
if (hasMatch) {
|
||||
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
|
||||
} else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) {
|
||||
next = current.filter((_, idx) => idx !== idOrPayload);
|
||||
} else {
|
||||
next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
|
||||
}
|
||||
deliveryEditorAutomat.setState({ attachments: next, files: next });
|
||||
},
|
||||
|
||||
resetDelivery() {
|
||||
deliveryEditorAutomat.setState({
|
||||
...initialDelivery,
|
||||
});
|
||||
},
|
||||
|
||||
resetDeliveryEditor() {
|
||||
deliveryEditorAutomat.setState({
|
||||
...initialState,
|
||||
});
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Returns the draft automat or the named automat belonging to a saved delivery.
|
||||
*
|
||||
* @param {string|number|null} docId Delivery identifier; `null` selects the draft.
|
||||
* @returns {Automat<DeliveryEditorState>} Delivery-specific editor automat.
|
||||
*/
|
||||
export function getDeliveryEditorAutomat(docId) {
|
||||
if (docId == null) {
|
||||
return deliveryDraftAutomat;
|
||||
}
|
||||
const name = `delivery-${docId}`;
|
||||
let automat = Automat.get(name);
|
||||
if (!automat) {
|
||||
automat = createDeliveryEditorAutomat({ name });
|
||||
}
|
||||
return automat;
|
||||
}
|
||||
|
||||
export const {
|
||||
setId,
|
||||
@@ -429,29 +660,52 @@ export const {
|
||||
removeLine,
|
||||
fetchSupplierArticles,
|
||||
fetchSupplierArticleForSku,
|
||||
unlinkSupplierArticle,
|
||||
addAttachment,
|
||||
setAttachments,
|
||||
updateAttachment,
|
||||
removeAttachment,
|
||||
setUnmatchedSupplierName,
|
||||
resetDelivery,
|
||||
resetDeliveryEditor,
|
||||
} = deliveryEditorAutomat.actions;
|
||||
|
||||
// Selectors
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
|
||||
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
|
||||
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
|
||||
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||
export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName;
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
|
||||
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
|
||||
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Array<DeliveryLine>} */
|
||||
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Array<*>} */
|
||||
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
|
||||
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Array<SupplierArticle>} */
|
||||
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle>} */
|
||||
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle|null>} */
|
||||
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
|
||||
/** @param {DeliveryEditorState} [state] @returns {boolean} */
|
||||
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
|
||||
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys;
|
||||
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
|
||||
export const selectBusySupplierArticleLineKeys = (state = deliveryEditorAutomat.state) =>
|
||||
state.busySupplierArticleLineKeys;
|
||||
|
||||
export default deliveryEditorAutomat;
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export * from './deliveryEditorAutomat';
|
||||
export { default as authAutomat } from './authAutomat';
|
||||
export * from './authAutomat';
|
||||
export { default as navigationAutomat } from './navigationAutomat';
|
||||
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
|
||||
63
src/automats/navigationAutomat.js
Normal file
63
src/automats/navigationAutomat.js
Normal 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;
|
||||
201
src/automats/supplierAutomat.js
Normal file
201
src/automats/supplierAutomat.js
Normal 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;
|
||||
@@ -11,13 +11,34 @@ import UserMenu from './UserMenu';
|
||||
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
||||
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
|
||||
import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
|
||||
import { navigationAutomat } from '../automats';
|
||||
|
||||
const SIDEBAR_WIDTH = 144;
|
||||
const SUB_NAV_WIDTH = 88;
|
||||
|
||||
class AppSidebar extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
|
||||
lastArticleSku: navigationAutomat.state.lastArticleSku,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = navigationAutomat.subscribe(this, (state) => ({
|
||||
lastDeliveryPath: state.lastDeliveryPath,
|
||||
lastArticleSku: state.lastArticleSku,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { activeNav, navigate } = this.props;
|
||||
const { lastDeliveryPath, lastArticleSku } = this.state;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -45,7 +66,21 @@ class AppSidebar extends PureComponent {
|
||||
selected={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
navigate(path === '/artikel' ? artikelQueryPath() : path);
|
||||
if (path === '/artikel') {
|
||||
if (lastArticleSku) {
|
||||
navigate(`/artikel/${lastArticleSku}`);
|
||||
} else {
|
||||
navigate(artikelQueryPath());
|
||||
}
|
||||
} else if (path === '/lieferung') {
|
||||
if (lastDeliveryPath) {
|
||||
navigate(lastDeliveryPath);
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
sx={(theme) => ({
|
||||
borderRadius: 0,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
|
||||
let sessionBootstrapped = false;
|
||||
|
||||
@@ -19,7 +18,7 @@ class AuthBootstrap extends Component {
|
||||
if (session.forbidden) {
|
||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||
}
|
||||
return this.props.bootstrapSession(session).unwrap();
|
||||
return authAutomat.actions.bootstrapSession(session);
|
||||
})
|
||||
.catch((err) => {
|
||||
notifyError(err);
|
||||
@@ -31,8 +30,4 @@ class AuthBootstrap extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(AuthBootstrap);
|
||||
export default AuthBootstrap;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -23,11 +22,7 @@ import BusinessIcon from '@mui/icons-material/Business';
|
||||
import BackendError from './BackendError';
|
||||
import { company } from '../api';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import {
|
||||
createCompany as createCompanyThunk,
|
||||
logout as logoutThunk,
|
||||
selectCompany as selectCompanyThunk,
|
||||
} from '../store/authSlice';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import {
|
||||
defaultIso639,
|
||||
ISO639_OPTIONS,
|
||||
@@ -75,7 +70,7 @@ class CompanySetupDialog extends Component {
|
||||
async handleSelect(companyId) {
|
||||
this.setState({ busy: true, error: null });
|
||||
try {
|
||||
await this.props.selectCompany(companyId).unwrap();
|
||||
await authAutomat.actions.selectCompany(companyId);
|
||||
await this.context.updateAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
@@ -102,14 +97,12 @@ class CompanySetupDialog extends Component {
|
||||
|
||||
this.setState({ busy: true, error: null });
|
||||
try {
|
||||
await this.props
|
||||
.createCompany({
|
||||
name: name.trim(),
|
||||
iso639,
|
||||
taxrate: parsedTaxrate,
|
||||
reducedtaxrate: parsedReducedtaxrate,
|
||||
})
|
||||
.unwrap();
|
||||
await authAutomat.actions.createCompany({
|
||||
name: name.trim(),
|
||||
iso639,
|
||||
taxrate: parsedTaxrate,
|
||||
reducedtaxrate: parsedReducedtaxrate,
|
||||
});
|
||||
await this.context.updateAuthMe();
|
||||
await this.context.updateCompany();
|
||||
} catch (err) {
|
||||
@@ -121,7 +114,7 @@ class CompanySetupDialog extends Component {
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
await this.props.logout().unwrap();
|
||||
await authAutomat.actions.logout();
|
||||
await this.context.updateAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
@@ -293,10 +286,4 @@ class CompanySetupDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
createCompany: (payload) => dispatch(createCompanyThunk(payload)),
|
||||
selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)),
|
||||
logout: () => dispatch(logoutThunk()),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(CompanySetupDialog);
|
||||
export default CompanySetupDialog;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyErrorMessage } from '../utils/notify';
|
||||
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import {
|
||||
abortGoogleSignIn,
|
||||
@@ -16,17 +15,25 @@ import {
|
||||
requestGoogleIdToken,
|
||||
} from '../auth/googleSignIn';
|
||||
|
||||
class LoginPage extends Component {
|
||||
class LoginPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { busy: false };
|
||||
this.state = { ...authAutomat.state, busy: false };
|
||||
this.handleLogin = this.handleLogin.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = authAutomat.subscribe(this, ['status', 'googleClientId']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
async handleLogin() {
|
||||
const { googleClientId } = this.props;
|
||||
const { googleClientId } = this.state;
|
||||
abortGoogleSignIn();
|
||||
try {
|
||||
const idToken = await requestGoogleIdToken(googleClientId, {
|
||||
@@ -38,7 +45,7 @@ class LoginPage extends Component {
|
||||
},
|
||||
});
|
||||
this.setState({ busy: true });
|
||||
await this.props.loginWithGoogle(idToken).unwrap();
|
||||
await authAutomat.actions.loginWithGoogle(idToken);
|
||||
await this.context.updateAuthMe();
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleSignInCancelledError) return;
|
||||
@@ -74,8 +81,7 @@ class LoginPage extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { busy } = this.state;
|
||||
const { status, googleClientId } = this.props;
|
||||
const { busy, status, googleClientId } = this.state;
|
||||
const loading = status === 'loading';
|
||||
const ready = Boolean(googleClientId);
|
||||
|
||||
@@ -103,13 +109,4 @@ class LoginPage extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
status: selectAuthStatus(state),
|
||||
googleClientId: selectGoogleClientId(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
loginWithGoogle: (credential) => dispatch(loginWithGoogle(credential)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LoginPage);
|
||||
export default LoginPage;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { PureComponent } from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -11,12 +10,12 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import BusinessIcon from '@mui/icons-material/Business';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import { logout } from '../store/authSlice';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import CompanySettingsDialog from './CompanySettingsDialog';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
class UserMenu extends Component {
|
||||
class UserMenu extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
@@ -25,7 +24,7 @@ class UserMenu extends Component {
|
||||
anchorEl: null,
|
||||
settingsOpen: false,
|
||||
numbersOpen: false,
|
||||
user: null,
|
||||
user: authAutomat.state.user,
|
||||
};
|
||||
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||
@@ -37,16 +36,11 @@ class UserMenu extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
void this.loadUser();
|
||||
this.unsubscribe = authAutomat.subscribe(this, 'user');
|
||||
}
|
||||
|
||||
async loadUser() {
|
||||
try {
|
||||
const session = await this.context.authMePromise;
|
||||
this.setState({ user: session.user });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
handleOpenMenu(event) {
|
||||
@@ -76,7 +70,7 @@ class UserMenu extends Component {
|
||||
async handleLogout() {
|
||||
this.setState({ anchorEl: null });
|
||||
try {
|
||||
await this.props.logout().unwrap();
|
||||
await authAutomat.actions.logout();
|
||||
await this.context.updateAuthMe();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -134,8 +128,4 @@ class UserMenu extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
logout: () => dispatch(logout()),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(UserMenu);
|
||||
export default UserMenu;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildCategoryPath,
|
||||
defaultCategoryPath,
|
||||
} from '../../pages/artikel/categoryRoute';
|
||||
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
|
||||
|
||||
import ArticleTileTab from './ArticleTileTab';
|
||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||
@@ -24,34 +25,52 @@ class ArticleView extends PureComponent {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
const { article, unwatch } = context.article.watch(
|
||||
props.sku,
|
||||
(next) => {
|
||||
this.setState({ article: next });
|
||||
},
|
||||
);
|
||||
this.state = { article };
|
||||
this.unwatchArticle = unwatch;
|
||||
|
||||
const sku = props.sku;
|
||||
const currentArticle = articleAutomat.state[sku] ?? null;
|
||||
|
||||
this.state = { article: currentArticle };
|
||||
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { sku } = this.props;
|
||||
|
||||
if (prevProps.sku !== sku) {
|
||||
this.unwatchArticle?.();
|
||||
const { article: next, unwatch } = this.context.article.watch(
|
||||
sku,
|
||||
(updated) => this.setState({ article: updated }),
|
||||
);
|
||||
this.unwatchArticle = unwatch;
|
||||
this.setState({ article: next });
|
||||
|
||||
if (!currentArticle) {
|
||||
articleAutomat.actions.loadArticle(sku).catch((err) => {
|
||||
console.error('Failed to load article:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.subscribeToArticle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
const currentArticle = articleAutomat.state[this.props.sku] ?? null;
|
||||
this.setState({ article: currentArticle });
|
||||
|
||||
if (!currentArticle) {
|
||||
articleAutomat.actions.loadArticle(this.props.sku).catch((err) => {
|
||||
console.error('Failed to load article:', err);
|
||||
});
|
||||
}
|
||||
|
||||
this.subscribeToArticle();
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToArticle() {
|
||||
if (this.unsub) this.unsub();
|
||||
const selector = createArticleSelector(this.props.sku);
|
||||
this.unsub = articleAutomat.subscribe(this, (state) => ({
|
||||
article: selector(state),
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unwatchArticle?.();
|
||||
this.unwatchArticle = null;
|
||||
if (this.unsub) {
|
||||
this.unsub();
|
||||
this.unsub = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleArticleDeleted() {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { enqueueSnackbar } from 'notistack';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { formatPrice } from '../../../utils/price';
|
||||
import supplierAutomat from '../../../automats/supplierAutomat';
|
||||
import ArticleSupplierDialog from '../ArticleSupplierDialog';
|
||||
|
||||
export default class ArticleSupplierTab extends PureComponent {
|
||||
@@ -80,7 +81,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
// 2. Fetch available company suppliers and company metadata
|
||||
try {
|
||||
const [companySuppliers, company] = await Promise.all([
|
||||
this.context.suppliers ?? this.context.supplierPromise,
|
||||
supplierAutomat.actions.load(),
|
||||
this.context.company ?? this.context.companyPromise,
|
||||
]);
|
||||
this.setState({
|
||||
|
||||
344
src/components/manifest/ArticleSearchDialog.jsx
Normal file
344
src/components/manifest/ArticleSearchDialog.jsx
Normal 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 „{query}“ 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,19 @@ export class Attachment extends PureComponent {
|
||||
if (onClick && attachment) {
|
||||
onClick(attachment);
|
||||
} else if (attachment?.file instanceof Blob || attachment?.file instanceof File) {
|
||||
const url = URL.createObjectURL(attachment.file);
|
||||
window.open(url, '_blank');
|
||||
if (attachment.file.size > 0) {
|
||||
const url = URL.createObjectURL(attachment.file);
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
const docId = attachment.manifestId;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (docId != null && fileId != null) {
|
||||
window.open(
|
||||
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,7 +60,8 @@ export class Attachment extends PureComponent {
|
||||
const name = attachment?.name || 'Dokument';
|
||||
const size = attachment?.size;
|
||||
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
|
||||
const isText = attachment?.type?.startsWith('text/') || name.toLowerCase().endsWith('.txt');
|
||||
const isCsv = attachment?.type === 'text/csv' || name.toLowerCase().endsWith('.csv');
|
||||
const isText = attachment?.type?.startsWith('text/') || name.toLowerCase().endsWith('.txt') || isCsv;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -125,7 +137,7 @@ export class Attachment extends PureComponent {
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
TXT
|
||||
{isCsv ? 'CSV' : 'TXT'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
|
||||
@@ -59,6 +59,7 @@ class ManifestForm extends PureComponent {
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -78,6 +79,10 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadPartiesOnly() {
|
||||
@@ -210,15 +215,21 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
this._parseAbortController = abortController;
|
||||
|
||||
this.setState({
|
||||
pdfUploading: true,
|
||||
pdfDialogOpen: true,
|
||||
pdfFileName: file.name,
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
|
||||
});
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -231,8 +242,8 @@ class ManifestForm extends PureComponent {
|
||||
const gen = this.context?.delivery?.parsePdfSse;
|
||||
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
|
||||
|
||||
for await (const { event, data } of this.context.delivery.parsePdfSse(file)) {
|
||||
if (this._cancelled) return;
|
||||
for await (const { event, data } of this.context.delivery.parsePdfSse(file, { signal: abortController.signal })) {
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
switch (event) {
|
||||
case 'status':
|
||||
appendMessage({ type: 'status', text: data.message ?? String(data) });
|
||||
@@ -267,6 +278,17 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
if (abortController.signal.aborted || err.name === 'AbortError') {
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
@@ -276,6 +298,9 @@ class ManifestForm extends PureComponent {
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
if (this._parseAbortController === abortController) {
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (!this._cancelled) {
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
@@ -287,9 +312,30 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleAbortParse() {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
pdfDialogData: prev.pdfDialogData
|
||||
? {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
handleClosePdfDialog() {
|
||||
this.setState({ pdfDialogOpen: false });
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState({ pdfDialogOpen: false, pdfUploading: false });
|
||||
}
|
||||
|
||||
async handleSave() {
|
||||
@@ -454,6 +500,7 @@ class ManifestForm extends PureComponent {
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, PureComponent } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
@@ -7,15 +8,19 @@ import IconButton from '@mui/material/IconButton';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
constructor(props) {
|
||||
@@ -23,6 +28,8 @@ class ManifestFormLine extends PureComponent {
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||
this.handleSearchArticle = this.handleSearchArticle.bind(this);
|
||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
@@ -42,6 +49,20 @@ class ManifestFormLine extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchArticle() {
|
||||
const { line, onSearchArticle } = this.props;
|
||||
if (onSearchArticle) {
|
||||
onSearchArticle(line);
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveSupplierArticle() {
|
||||
const { line, supplierArticle, onRemoveSupplierArticle } = this.props;
|
||||
if (onRemoveSupplierArticle) {
|
||||
onRemoveSupplierArticle(line, supplierArticle);
|
||||
}
|
||||
}
|
||||
|
||||
renderField(column) {
|
||||
const { id, align, type } = column;
|
||||
const { line, saving, onChange } = this.props;
|
||||
@@ -151,6 +172,9 @@ class ManifestFormLine extends PureComponent {
|
||||
{/* Internal Article SKU */}
|
||||
{article.sku && (
|
||||
<Chip
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(article)}
|
||||
clickable
|
||||
size="small"
|
||||
label={`Art.-Nr.: ${article.sku}`}
|
||||
sx={{
|
||||
@@ -161,6 +185,11 @@ class ManifestFormLine extends PureComponent {
|
||||
color: 'primary.dark',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.200',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.100',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -196,24 +225,6 @@ class ManifestFormLine extends PureComponent {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Stock */}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Bestand: ${supplierArticle.stock ?? 0}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
{/* Packaging Unit (PU) */}
|
||||
{supplierArticle.pu != null && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`VPE: ${supplierArticle.pu}`}
|
||||
sx={{ height: 19, fontSize: '0.66rem', color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stored Purchase Price (Netto) */}
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
@@ -225,6 +236,21 @@ class ManifestFormLine extends PureComponent {
|
||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Remove supplier-article connection */}
|
||||
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
|
||||
<span style={{ marginLeft: priceNet != null ? 0 : 'auto' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label="Lieferanten-Verknüpfung entfernen"
|
||||
disabled={this.props.saving || this.props.supplierArticleBusy}
|
||||
onClick={this.handleRemoveSupplierArticle}
|
||||
>
|
||||
<LinkOffOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -258,6 +284,28 @@ class ManifestFormLine extends PureComponent {
|
||||
Artikel anlegen
|
||||
</Button>
|
||||
)}
|
||||
{this.props.onSearchArticle && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={this.handleSearchArticle}
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
minWidth: 'auto',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.50',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Artikel suchen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -298,13 +346,13 @@ class ManifestFormLine extends PureComponent {
|
||||
</TableRow>
|
||||
|
||||
{/* Row 2: Matched Supplier Article Details */}
|
||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}>
|
||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 2,
|
||||
bgcolor: 'rgba(248, 250, 252, 0.75)',
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{this.renderSecondLine()}
|
||||
|
||||
@@ -134,7 +134,7 @@ class ManifestHeader extends PureComponent {
|
||||
) : null}
|
||||
{onPdfUpload ? (
|
||||
<FileDropzone
|
||||
accept="application/pdf,.pdf"
|
||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||
disabled={saving || pdfUploading}
|
||||
onFileDrop={onPdfUpload}
|
||||
sx={{
|
||||
@@ -150,6 +150,7 @@ class ManifestHeader extends PureComponent {
|
||||
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={this.handleUploadClick}
|
||||
title="PDF, TXT oder CSV hochladen"
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
@@ -164,8 +165,8 @@ class ManifestHeader extends PureComponent {
|
||||
{pdfUploading
|
||||
? 'Wird analysiert…'
|
||||
: isDragging
|
||||
? 'PDF hier ablegen'
|
||||
: 'PDF hochladen'}
|
||||
? 'Datei hier ablegen'
|
||||
: 'Datei hochladen'}
|
||||
</Button>
|
||||
)}
|
||||
</FileDropzone>
|
||||
@@ -182,7 +183,7 @@ class ManifestHeader extends PureComponent {
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,9 @@ class ManifestLines extends PureComponent {
|
||||
loadingLineKeys = {},
|
||||
onSkuBlur,
|
||||
onCreateArticle,
|
||||
onSearchArticle,
|
||||
onRemoveSupplierArticle,
|
||||
busySupplierArticleLineKeys = {},
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -38,7 +41,7 @@ class ManifestLines extends PureComponent {
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableRow sx={{ '& th': { borderBottom: 'none', fontWeight: 700 } }}>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
@@ -47,12 +50,13 @@ class ManifestLines extends PureComponent {
|
||||
width: col.width,
|
||||
whiteSpace: 'nowrap',
|
||||
bgcolor: linesColor,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ bgcolor: linesColor }} />
|
||||
<TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -74,10 +78,13 @@ class ManifestLines extends PureComponent {
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -15,6 +15,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
@@ -91,16 +92,30 @@ function EventLog({ messages, streaming }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
export default function ManifestPdfDialog({
|
||||
open,
|
||||
filename,
|
||||
data,
|
||||
unmatchedSupplierName,
|
||||
onCreateSupplier,
|
||||
onClose,
|
||||
onAbort,
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
const { streaming = false, messages = [], result = null, error = null } = data ?? {};
|
||||
|
||||
const { streaming = false, messages = [], result = null, error = null, aborted = false } = data ?? {};
|
||||
const hasResult = Boolean(result);
|
||||
|
||||
const isTextDoc =
|
||||
filename?.toLowerCase().endsWith('.txt') || filename?.toLowerCase().endsWith('.csv');
|
||||
|
||||
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={streaming ? undefined : onClose}
|
||||
onClose={handleAbortOrClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
aria-labelledby="pdf-dialog-title"
|
||||
@@ -117,16 +132,26 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
{aborted ? (
|
||||
<ErrorOutlinedIcon color="warning" />
|
||||
) : error ? (
|
||||
<ErrorOutlinedIcon color="error" />
|
||||
) : streaming ? (
|
||||
<CircularProgress size={22} />
|
||||
) : isTextDoc ? (
|
||||
<TextSnippetOutlinedIcon color="action" />
|
||||
) : (
|
||||
<PictureAsPdfIcon color="action" />
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" component="div" fontWeight={600}>
|
||||
{streaming ? 'PDF wird analysiert…' : error ? 'PDF-Analyse fehlgeschlagen' : 'PDF-Analyse abgeschlossen'}
|
||||
{streaming
|
||||
? (isTextDoc ? 'Dokument wird analysiert…' : 'PDF wird analysiert…')
|
||||
: aborted
|
||||
? 'Analyse abgebrochen'
|
||||
: error
|
||||
? 'Analyse fehlgeschlagen'
|
||||
: (isTextDoc ? 'Dokument-Analyse abgeschlossen' : 'PDF-Analyse abgeschlossen')}
|
||||
</Typography>
|
||||
{filename ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -136,9 +161,8 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
</Box>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
onClick={handleAbortOrClose}
|
||||
size="small"
|
||||
disabled={streaming}
|
||||
sx={{ color: (theme) => theme.palette.grey[500] }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
@@ -146,9 +170,9 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 2 }}>
|
||||
{/* Error */}
|
||||
{/* Error or Aborted Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" icon={<ErrorOutlinedIcon />} sx={{ mb: 2 }}>
|
||||
<Alert severity={aborted ? 'warning' : 'error'} icon={aborted ? undefined : <ErrorOutlinedIcon />} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" fontWeight={600}>{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -157,18 +181,69 @@ export default function ManifestPdfDialog({ open, filename, data, onClose }) {
|
||||
<EventLog messages={messages} streaming={streaming} />
|
||||
|
||||
{/* Success notification */}
|
||||
{hasResult && !streaming && !error && (
|
||||
{hasResult && !streaming && !error && !aborted && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Daten erfolgreich in das Formular übernommen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Unmatched supplier alert */}
|
||||
{hasResult && !streaming && !error && !aborted && unmatchedSupplierName && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mt: 1.5, alignItems: 'center' }}
|
||||
action={
|
||||
onCreateSupplier ? (
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onCreateSupplier(unmatchedSupplierName)}
|
||||
>
|
||||
Lieferant anlegen
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Lieferant „{unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 1.5, px: 2 }}>
|
||||
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}>
|
||||
Schließen
|
||||
</Button>
|
||||
{streaming ? (
|
||||
<Button
|
||||
onClick={onAbort || onClose}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<CloseIcon />}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{unmatchedSupplierName && onCreateSupplier ? (
|
||||
<Button
|
||||
onClick={() => onCreateSupplier(unmatchedSupplierName)}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
>
|
||||
Lieferant „{unmatchedSupplierName}“ anlegen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant={unmatchedSupplierName && onCreateSupplier ? 'outlined' : 'contained'}
|
||||
size="small"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -12,10 +13,15 @@ import Chip from '@mui/material/Chip';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
|
||||
export class TextEditorDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -26,6 +32,7 @@ export class TextEditorDialog extends PureComponent {
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handleCopy = this.handleCopy.bind(this);
|
||||
this.handleDownload = this.handleDownload.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -34,12 +41,16 @@ export class TextEditorDialog extends PureComponent {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) {
|
||||
this.loadContent();
|
||||
if (this.props.open) {
|
||||
this.loadContent();
|
||||
} else {
|
||||
this.setState({ content: '', initialContent: '', loading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadContent() {
|
||||
const { attachment, open } = this.props;
|
||||
const { attachment, open, docId, onContentLoad } = this.props;
|
||||
if (!open || !attachment) return;
|
||||
|
||||
if (typeof attachment.text === 'string') {
|
||||
@@ -51,7 +62,10 @@ export class TextEditorDialog extends PureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.file instanceof Blob || attachment.file instanceof File) {
|
||||
if (
|
||||
(attachment.file instanceof Blob || attachment.file instanceof File) &&
|
||||
attachment.file.size > 0
|
||||
) {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const text = await attachment.file.text();
|
||||
@@ -60,11 +74,44 @@ export class TextEditorDialog extends PureComponent {
|
||||
initialContent: text,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Lesen der Textdatei:', err);
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
|
||||
const effectiveDocId = docId ?? attachment.manifestId;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (effectiveDocId != null && fileId != null) {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const getFile =
|
||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
||||
deliveryApi.getFile;
|
||||
const blob = await getFile(effectiveDocId, fileId);
|
||||
const text = await blob.text();
|
||||
this.setState({
|
||||
content: text,
|
||||
initialContent: text,
|
||||
loading: false,
|
||||
});
|
||||
onContentLoad?.(attachment, text, blob);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Textdatei:', err);
|
||||
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
|
||||
this.setState({ loading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
content: '',
|
||||
initialContent: '',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
@@ -88,6 +135,22 @@ export class TextEditorDialog extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleDownload() {
|
||||
const { attachment } = this.props;
|
||||
const { content } = this.state;
|
||||
if (!content) return;
|
||||
const filename = attachment?.name || 'Dokument.txt';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, onClose, attachment, readOnly = false } = this.props;
|
||||
if (!open) return null;
|
||||
@@ -152,8 +215,24 @@ export class TextEditorDialog extends PureComponent {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: '#fafafa',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(250, 250, 250, 0.7)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={36} />
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
@@ -216,15 +295,26 @@ export class TextEditorDialog extends PureComponent {
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={this.handleCopy}
|
||||
disabled={loading || !content}
|
||||
>
|
||||
Kopieren
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={this.handleCopy}
|
||||
disabled={loading || !content}
|
||||
>
|
||||
Kopieren
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<DownloadOutlinedIcon />}
|
||||
onClick={this.handleDownload}
|
||||
disabled={loading || !content}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button size="small" onClick={onClose}>
|
||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
||||
|
||||
@@ -6,6 +6,7 @@ export { default as ManifestFormLine } from './ManifestFormLine';
|
||||
export { default as Attachment } from './Attachment';
|
||||
export { default as AttachmentList } from './AttachmentList';
|
||||
export { default as TextEditorDialog } from './TextEditorDialog';
|
||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||
export { withManifestId } from './withManifestId';
|
||||
export {
|
||||
manifestConfig,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import supplierAutomat from '../../automats/supplierAutomat';
|
||||
|
||||
/** Groups that are not sales customers (filter client-side). */
|
||||
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
||||
|
||||
@@ -173,20 +175,6 @@ export async function buildFilesForUpload(attachments = [], { onlyNew = false }
|
||||
filePayload.thumbavif = thumbBase64;
|
||||
}
|
||||
|
||||
const meta = {};
|
||||
if (attachment.thumbnail) {
|
||||
meta.thumbnail = attachment.thumbnail;
|
||||
}
|
||||
if (attachment.text) {
|
||||
meta.text = attachment.text;
|
||||
}
|
||||
if (attachment.parentPdfId) {
|
||||
meta.parentPdfId = String(attachment.parentPdfId);
|
||||
}
|
||||
if (Object.keys(meta).length > 0) {
|
||||
filePayload.meta = meta;
|
||||
}
|
||||
|
||||
files.push(filePayload);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
|
||||
@@ -268,12 +256,9 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
|
||||
if (apiFile.thumbavif) {
|
||||
try {
|
||||
if (typeof apiFile.thumbavif === 'string') {
|
||||
if (apiFile.thumbavif.startsWith('data:')) {
|
||||
thumbnail = apiFile.thumbavif;
|
||||
} else {
|
||||
const thumbBlob = base64ToBlob(apiFile.thumbavif, 'image/avif');
|
||||
thumbnail = URL.createObjectURL(thumbBlob);
|
||||
}
|
||||
thumbnail = apiFile.thumbavif.startsWith('data:')
|
||||
? apiFile.thumbavif
|
||||
: `data:image/avif;base64,${apiFile.thumbavif}`;
|
||||
} else if (typeof apiFile.thumbavif === 'object') {
|
||||
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
|
||||
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) {
|
||||
@@ -286,24 +271,7 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check meta.thumbnail if thumbavif wasn't available
|
||||
if (!thumbnail && apiFile.meta?.thumbnail) {
|
||||
try {
|
||||
const metaThumb = apiFile.meta.thumbnail;
|
||||
if (typeof metaThumb === 'string') {
|
||||
if (metaThumb.startsWith('data:') || metaThumb.startsWith('blob:')) {
|
||||
thumbnail = metaThumb;
|
||||
} else {
|
||||
const thumbBlob = base64ToBlob(metaThumb, 'image/avif');
|
||||
thumbnail = URL.createObjectURL(thumbBlob);
|
||||
}
|
||||
}
|
||||
} catch (metaThumbErr) {
|
||||
console.error('Fehler beim Dekodieren von meta.thumbnail:', metaThumbErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to existing matching attachment thumbnail
|
||||
// 2. Fallback to existing matching attachment thumbnail
|
||||
const matchingExisting =
|
||||
existingMap.get(String(apiFile.id)) ||
|
||||
existingMap.get(String(apiFile.fileId)) ||
|
||||
@@ -320,8 +288,8 @@ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachmen
|
||||
? matchingExisting.file
|
||||
: new File([''], placeholderName, { type: placeholderType });
|
||||
|
||||
const text = apiFile.meta?.text || matchingExisting?.text || null;
|
||||
const parentPdfId = apiFile.meta?.parentPdfId || matchingExisting?.parentPdfId || null;
|
||||
const text = matchingExisting?.text || null;
|
||||
const parentPdfId = matchingExisting?.parentPdfId || null;
|
||||
|
||||
attachments.push({
|
||||
id: apiFile.id,
|
||||
@@ -373,19 +341,10 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
|
||||
}
|
||||
}
|
||||
|
||||
const meta = {};
|
||||
if (attachment.parentPdfId) {
|
||||
meta.parentPdfId = String(attachment.parentPdfId);
|
||||
}
|
||||
if (attachment.text) {
|
||||
meta.text = String(attachment.text);
|
||||
}
|
||||
|
||||
files.push({
|
||||
file: fileToUpload,
|
||||
name: attachment.name || fileToUpload.name,
|
||||
mimetype: attachment.type || fileToUpload.type,
|
||||
meta: Object.keys(meta).length > 0 ? meta : undefined,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
@@ -429,14 +388,6 @@ export async function buildManifestBody(
|
||||
const isUpdate = !requireAtLeastOne;
|
||||
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
|
||||
if (filesToUpload.length > 0) {
|
||||
const filesMeta = filesToUpload
|
||||
.filter((f) => f.meta)
|
||||
.map((f) => ({ name: f.name, meta: f.meta }));
|
||||
|
||||
if (filesMeta.length > 0) {
|
||||
body.filesMeta = filesMeta;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify(body));
|
||||
for (const item of filesToUpload) {
|
||||
@@ -475,7 +426,10 @@ export const DELIVERY_LINE_COLUMNS = [
|
||||
*/
|
||||
export async function loadParties(kind, ctx) {
|
||||
if (kind === 'delivery') {
|
||||
const list = ctx.suppliers ?? (await ctx.supplierPromise);
|
||||
if (supplierAutomat.state.status === 'ready') {
|
||||
return sortSuppliers(supplierAutomat.state.suppliers);
|
||||
}
|
||||
const list = await supplierAutomat.actions.load();
|
||||
return sortSuppliers(list);
|
||||
}
|
||||
const [entities, groups] = await Promise.all([
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function withNavigate(Wrapped) {
|
||||
function NavigateWrapper(props) {
|
||||
const navigate = useNavigate();
|
||||
return <Wrapped {...props} navigate={navigate} />;
|
||||
const location = useLocation();
|
||||
return <Wrapped {...props} navigate={navigate} location={location} />;
|
||||
}
|
||||
return NavigateWrapper;
|
||||
}
|
||||
|
||||
|
||||
537
src/devtools/AutomatInspector.jsx
Normal file
537
src/devtools/AutomatInspector.jsx
Normal 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;
|
||||
13
src/main.jsx
13
src/main.jsx
@@ -1,4 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -8,8 +9,15 @@ import { StorageManagerProvider } from './api/storageManagerContext';
|
||||
import App from './App';
|
||||
import { store } from './store';
|
||||
import { getActiveTheme } from './theme';
|
||||
import './automats/supplierAutomat';
|
||||
import './index.css';
|
||||
|
||||
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
|
||||
// eliminated in production builds, so this module never ships to users.
|
||||
const AutomatInspector = import.meta.env.DEV
|
||||
? lazy(() => import('./devtools/AutomatInspector'))
|
||||
: null;
|
||||
|
||||
async function clearDevServiceWorker() {
|
||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
@@ -36,6 +44,11 @@ createRoot(document.getElementById('root')).render(
|
||||
<StorageManagerProvider>
|
||||
<AuthBootstrap>
|
||||
<App />
|
||||
{AutomatInspector ? (
|
||||
<Suspense fallback={null}>
|
||||
<AutomatInspector />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AuthBootstrap>
|
||||
</StorageManagerProvider>
|
||||
</SnackbarProvider>
|
||||
|
||||
80
src/nav.js
80
src/nav.js
@@ -1,3 +1,5 @@
|
||||
import { PureComponent, createElement } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
@@ -13,15 +15,89 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
|
||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import { isKategorienPath } from './pages/artikel/categoryRoute';
|
||||
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
||||
|
||||
class DeliveryNavIcon extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const state = deliveryEditorAutomat.state;
|
||||
const isDraft = state.id == null && state.deliveryId == null;
|
||||
this.state = {
|
||||
hasDraft: isDraft && (state.lines?.length || 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => {
|
||||
const isDraft = state.id == null && state.deliveryId == null;
|
||||
return {
|
||||
hasDraft: isDraft && (state.lines?.length || 0) > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasDraft } = this.state;
|
||||
const { sx, ...rest } = this.props;
|
||||
return createElement(LocalShippingIcon, {
|
||||
...rest,
|
||||
sx: [
|
||||
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
|
||||
(theme) => (hasDraft ? { color: theme.nav?.itemActiveAccent || '#2dd4bf' } : null),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon },
|
||||
{ path: '/lieferung', label: 'Lieferung', icon: LocalShippingIcon },
|
||||
{ path: '/lieferung', label: 'Lieferung', icon: DeliveryNavIcon },
|
||||
{ path: '/auftrag', label: 'Auftrag', icon: AssignmentIcon },
|
||||
{ path: '/kunde', label: 'Kunde', icon: PeopleIcon },
|
||||
{ path: '/infos', label: 'Infos', icon: InfoIcon },
|
||||
];
|
||||
|
||||
class DeliveryNeuIcon extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const state = deliveryEditorAutomat.state;
|
||||
const isDraft = state.id == null && state.deliveryId == null;
|
||||
this.state = {
|
||||
badgeCount: isDraft ? (state.lines?.length || 0) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => {
|
||||
const isDraft = state.id == null && state.deliveryId == null;
|
||||
return {
|
||||
badgeCount: isDraft ? (state.lines?.length || 0) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
return createElement(
|
||||
Badge,
|
||||
{
|
||||
badgeContent: this.state.badgeCount,
|
||||
invisible: !this.state.badgeCount,
|
||||
color: 'primary',
|
||||
max: 999,
|
||||
},
|
||||
createElement(AddBoxIcon, this.props)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const SUB_NAV = {
|
||||
'/artikel': [
|
||||
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
|
||||
@@ -30,7 +106,7 @@ export const SUB_NAV = {
|
||||
],
|
||||
'/lieferung': [
|
||||
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
|
||||
{ segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: AddBoxIcon },
|
||||
{ segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: DeliveryNeuIcon },
|
||||
{ segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon },
|
||||
],
|
||||
'/auftrag': [
|
||||
|
||||
@@ -9,8 +9,7 @@ import Paper from '@mui/material/Paper';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import {
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
import { preserveSearchFocus } from './searchFocus';
|
||||
import { artikelDetailPath } from './articleRoute';
|
||||
import withSearchParams from './withSearchParams';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
|
||||
const resultRowSx = {
|
||||
alignItems: 'center',
|
||||
@@ -83,6 +83,10 @@ function initialSearchState(searchParams) {
|
||||
class ArtikelSearchPage extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
get storageManager() {
|
||||
return this.context || getStorageManager();
|
||||
}
|
||||
|
||||
state = initialSearchState(this.props.searchParams);
|
||||
listSlotRef = { current: null };
|
||||
rowMeasureRef = { current: null };
|
||||
@@ -168,10 +172,11 @@ class ArtikelSearchPage extends Component {
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
setLastArticleSku(null);
|
||||
|
||||
// Kick off initial search (empty input = show all products).
|
||||
const { input } = this.state;
|
||||
this.context.doSearch({
|
||||
this.storageManager?.doSearch({
|
||||
target: input,
|
||||
filter: this.props.searchParams.get('filter') || undefined,
|
||||
offset: 0,
|
||||
@@ -220,7 +225,7 @@ class ArtikelSearchPage extends Component {
|
||||
const value = event.target.value;
|
||||
this.setState({ input: value });
|
||||
|
||||
this.context.doSearch({
|
||||
this.storageManager?.doSearch({
|
||||
target: value,
|
||||
filter: this.props.searchParams.get('filter') || undefined,
|
||||
offset: 0,
|
||||
@@ -240,7 +245,7 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
// Delegate fetch + debounce to the global StorageManager.
|
||||
// The callback pushes results into local component state.
|
||||
this.context.doSearch({
|
||||
this.storageManager?.doSearch({
|
||||
target: trimmed,
|
||||
filter: filter || undefined,
|
||||
offset,
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
enableTerminalCategory,
|
||||
disableTerminalCategory,
|
||||
} from '../../store/posterminalSlice';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
|
||||
const gridSx = {
|
||||
display: 'grid',
|
||||
@@ -72,6 +73,7 @@ class ArtikelKategorienPage extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setLastArticleSku(null);
|
||||
this.ensureTreeLoaded();
|
||||
this.ensurePosterminalsLoaded();
|
||||
this.syncCategoryFromUrl();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
|
||||
export default function ArtikelLagerPage() {
|
||||
useEffect(() => {
|
||||
setLastArticleSku(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Typography color="text.secondary">Lager — Inhalt folgt.</Typography>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from 'react';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { ArticleView } from '../../components/article';
|
||||
import withSkuRoute from './withSkuRoute';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
|
||||
class ArtikelSkuPage extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
@@ -17,11 +18,13 @@ class ArtikelSkuPage extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.installEscape();
|
||||
setLastArticleSku(this.props.sku);
|
||||
if (!this.state.ready) void this.ensureArticle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
setLastArticleSku(this.props.sku);
|
||||
const ready =
|
||||
this.context.getArticle(this.props.sku) != null &&
|
||||
this.context.getCategoryState() != null;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestList } from '../../components/manifest';
|
||||
import { setLastDeliveryPath } from '../../automats';
|
||||
|
||||
class LieferungListePage extends PureComponent {
|
||||
componentDidMount() {
|
||||
setLastDeliveryPath(null);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ManifestList kind="delivery" />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -13,10 +15,13 @@ import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import withNavigate from '../../components/withNavigate';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { setLastDeliveryPath } from '../../automats';
|
||||
import supplierAutomat from '../../automats/supplierAutomat';
|
||||
import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
|
||||
|
||||
function sortSuppliers(suppliers) {
|
||||
return [...suppliers].sort((a, b) =>
|
||||
@@ -28,17 +33,32 @@ function draftsFrom(suppliers) {
|
||||
return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
class LieferungLieferantenPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
function getInitialParams(props) {
|
||||
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const prefill =
|
||||
props.location?.state?.name ??
|
||||
props.location?.state?.newName ??
|
||||
searchParams?.get('name') ??
|
||||
searchParams?.get('newName') ??
|
||||
deliveryDraftAutomat.state.unmatchedSupplierName ??
|
||||
'';
|
||||
const returnTo =
|
||||
props.location?.state?.returnTo ??
|
||||
searchParams?.get('returnTo') ??
|
||||
null;
|
||||
return { prefill: prefill.trim(), returnTo };
|
||||
}
|
||||
|
||||
class LieferungLieferantenPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { prefill } = getInitialParams(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
suppliers: [],
|
||||
drafts: {},
|
||||
newName: '',
|
||||
newName: prefill,
|
||||
busyKey: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
@@ -48,10 +68,27 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { returnTo, prefill } = getInitialParams(this.props);
|
||||
if (!returnTo) {
|
||||
setLastDeliveryPath(null);
|
||||
}
|
||||
if (prefill && !this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
this._cancelled = false;
|
||||
void this.loadSuppliers();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.location !== this.props.location) {
|
||||
const { prefill } = getInitialParams(this.props);
|
||||
if (prefill && prefill !== this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
@@ -69,7 +106,7 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
async loadSuppliers() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const list = this.context.suppliers ?? (await this.context.supplierPromise);
|
||||
const list = await supplierAutomat.actions.load();
|
||||
if (this._cancelled) return;
|
||||
this.applySuppliers(list);
|
||||
} catch (err) {
|
||||
@@ -83,7 +120,7 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
}
|
||||
|
||||
async refreshFromServer() {
|
||||
const list = await this.context.updateSupplier();
|
||||
const list = await supplierAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.applySuppliers(list);
|
||||
}
|
||||
@@ -92,16 +129,24 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name || this.state.busyKey != null) return;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
this.setState({ busyKey: 'new' });
|
||||
try {
|
||||
const data = await this.context.supplier.create({ name });
|
||||
requireObject(data, 'POST /api/supplier');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
const newSupplier = await supplierAutomat.actions.create({ name });
|
||||
|
||||
// Assign the new supplier to the active delivery draft & clear unmatched name
|
||||
deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
|
||||
deliveryDraftAutomat.actions.setUnmatchedSupplierName(null);
|
||||
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ newName: '', busyKey: null });
|
||||
enqueueSnackbar('Lieferant angelegt.', { variant: 'success' });
|
||||
enqueueSnackbar(`Lieferant „${newSupplier.name || name}“ angelegt.`, { variant: 'success' });
|
||||
|
||||
if (returnTo && this.props.navigate) {
|
||||
this.props.navigate(returnTo);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
@@ -109,6 +154,7 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async handleRename(supplier) {
|
||||
const draft = String(this.state.drafts[supplier.id] ?? '').trim();
|
||||
if (!draft) {
|
||||
@@ -121,10 +167,7 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
|
||||
this.setState({ busyKey: supplier.id });
|
||||
try {
|
||||
const data = await this.context.supplier.update(supplier.id, { name: draft });
|
||||
requireObject(data, 'PATCH /api/supplier/:id');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
await supplierAutomat.actions.update(supplier.id, { name: draft });
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
@@ -147,7 +190,7 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
|
||||
this.setState({ busyKey: supplier.id });
|
||||
try {
|
||||
await this.context.supplier.delete(supplier.id);
|
||||
await supplierAutomat.actions.remove(supplier.id);
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
@@ -163,6 +206,8 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
const { status, error, suppliers, drafts, newName, busyKey } = this.state;
|
||||
const busy = busyKey != null;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
@@ -181,6 +226,26 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
||||
<Stack spacing={2}>
|
||||
{returnTo ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ py: 0.5, alignItems: 'center' }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => this.props.navigate?.(returnTo)}
|
||||
>
|
||||
Zurück zur Lieferung
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{newName
|
||||
? `Lieferantenname „${newName}“ aus Dokument übernommen.`
|
||||
: 'Neuen Lieferanten für die Lieferung anlegen.'}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Lieferanten der ausgewählten Firma.
|
||||
</Typography>
|
||||
@@ -269,4 +334,5 @@ class LieferungLieferantenPage extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export default LieferungLieferantenPage;
|
||||
export default withNavigate(LieferungLieferantenPage);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
lineFromApi,
|
||||
manifestConfig,
|
||||
TextEditorDialog,
|
||||
ArticleSearchDialog,
|
||||
} from '../../components/manifest';
|
||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
||||
@@ -28,7 +31,9 @@ import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
|
||||
import { extractPdfText } from '../../utils/pdfText';
|
||||
import { deliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
import { setLastDeliveryPath } from '../../automats';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
|
||||
class LieferungNeuPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
@@ -36,9 +41,10 @@ class LieferungNeuPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig('delivery');
|
||||
this.automat = getDeliveryEditorAutomat(props.docId);
|
||||
this.state = {
|
||||
...deliveryEditorAutomat.state,
|
||||
status: 'loadingParties',
|
||||
...this.automat.state,
|
||||
status: props.docId != null ? 'loading' : 'loadingParties',
|
||||
error: null,
|
||||
partiesLoading: true,
|
||||
saving: false,
|
||||
@@ -48,11 +54,16 @@ class LieferungNeuPage extends PureComponent {
|
||||
pdfFileName: '',
|
||||
textEditorOpen: false,
|
||||
textEditorAttachment: null,
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
unmatchedSupplierName: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
|
||||
this.handleDateChange = this.handleDateChange.bind(this);
|
||||
|
||||
this.handleNumberChange = this.handleNumberChange.bind(this);
|
||||
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
||||
this.handleLineChange = this.handleLineChange.bind(this);
|
||||
@@ -63,41 +74,58 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||
this.handlePdfDelete = this.handlePdfDelete.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
||||
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
|
||||
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
|
||||
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
|
||||
this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
|
||||
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
|
||||
this.handleSkuBlur = this.handleSkuBlur.bind(this);
|
||||
this.handleCreateArticleForLine = this.handleCreateArticleForLine.bind(this);
|
||||
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
|
||||
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
|
||||
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
|
||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = deliveryEditorAutomat.subscribe(this);
|
||||
this.unsubscribe = this.automat.subscribe(this);
|
||||
this._cancelled = false;
|
||||
if (this.props.docId != null) {
|
||||
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
|
||||
void this.loadEdit();
|
||||
} else {
|
||||
setLastDeliveryPath('/lieferung/neu');
|
||||
void this.loadPartiesOnly();
|
||||
if (this.props.initialDelivery) {
|
||||
deliveryEditorAutomat.actions.setDelivery(this.props.initialDelivery);
|
||||
} else {
|
||||
deliveryEditorAutomat.actions.resetDelivery();
|
||||
this.automat.actions.setDelivery(this.props.initialDelivery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.docId !== this.props.docId) {
|
||||
this.unsubscribe?.();
|
||||
this.automat = getDeliveryEditorAutomat(this.props.docId);
|
||||
this.unsubscribe = this.automat.subscribe(this);
|
||||
this.setState({ ...this.automat.state });
|
||||
if (this.props.docId != null) {
|
||||
void this.loadEdit();
|
||||
} else {
|
||||
void this.loadPartiesOnly();
|
||||
deliveryEditorAutomat.actions.resetDelivery();
|
||||
if (this.props.initialDelivery) {
|
||||
this.automat.actions.setDelivery(this.props.initialDelivery);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (this._skuDebounceTimers) {
|
||||
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
|
||||
this._skuDebounceTimers = null;
|
||||
@@ -110,7 +138,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
try {
|
||||
const parties = await loadParties('delivery', this.context);
|
||||
if (this._cancelled) return;
|
||||
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
|
||||
this.automat.actions.setDeliveryEntities(parties);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
partiesLoading: false,
|
||||
@@ -145,7 +173,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
|
||||
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
|
||||
this.automat.actions.setDeliveryEntities(parties);
|
||||
|
||||
requireObject(docRes, cfg.getPathLabel);
|
||||
requireObject(docRes[cfg.docField], cfg.docField);
|
||||
@@ -154,17 +182,17 @@ class LieferungNeuPage extends PureComponent {
|
||||
const lines =
|
||||
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
|
||||
|
||||
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
|
||||
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
|
||||
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
|
||||
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
|
||||
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
|
||||
deliveryEditorAutomat.actions.setLineitems(lines);
|
||||
this.automat.actions.setId(doc.id ?? docId);
|
||||
this.automat.actions.setDate(dateInputValue(doc.date));
|
||||
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
|
||||
if (doc.number != null) this.automat.actions.setNumber(doc.number);
|
||||
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
|
||||
this.automat.actions.setLineitems(lines);
|
||||
|
||||
// Restore attachments with thumbnails
|
||||
const apiFiles = doc.files ?? [];
|
||||
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
|
||||
deliveryEditorAutomat.actions.setAttachments(attachments);
|
||||
this.automat.actions.setAttachments(attachments);
|
||||
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
@@ -186,19 +214,31 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
|
||||
handlePartyChange(partyId) {
|
||||
deliveryEditorAutomat.actions.setDeliveryEntity(partyId);
|
||||
this.automat.actions.setDeliveryEntity(partyId);
|
||||
if (partyId != null) {
|
||||
this.setState({ unmatchedSupplierName: null });
|
||||
this.automat.actions.setUnmatchedSupplierName(null);
|
||||
}
|
||||
}
|
||||
|
||||
handleCreateSupplierFromPdf(supplierName) {
|
||||
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
|
||||
this.handleClosePdfDialog();
|
||||
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
|
||||
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
|
||||
}
|
||||
|
||||
|
||||
handleDateChange(date) {
|
||||
deliveryEditorAutomat.actions.setDate(date);
|
||||
this.automat.actions.setDate(date);
|
||||
}
|
||||
|
||||
handleNumberChange(number) {
|
||||
deliveryEditorAutomat.actions.setNumber(number);
|
||||
this.automat.actions.setNumber(number);
|
||||
}
|
||||
|
||||
handleExternalNumberChange(externalNumber) {
|
||||
deliveryEditorAutomat.actions.setExternalNumber(externalNumber);
|
||||
this.automat.actions.setExternalNumber(externalNumber);
|
||||
}
|
||||
|
||||
findLineIndex(key) {
|
||||
@@ -214,7 +254,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
handleLineChange(key, field, value) {
|
||||
const index = this.findLineIndex(key);
|
||||
deliveryEditorAutomat.actions.updateLineitem({
|
||||
this.automat.actions.updateLineitem({
|
||||
index,
|
||||
key,
|
||||
[field]: value,
|
||||
@@ -229,7 +269,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
this._skuDebounceTimers[key] = setTimeout(() => {
|
||||
if (this._skuDebounceTimers) delete this._skuDebounceTimers[key];
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
|
||||
this.automat.actions.fetchSupplierArticleForSku(value, key);
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +279,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
clearTimeout(this._skuDebounceTimers[key]);
|
||||
delete this._skuDebounceTimers[key];
|
||||
}
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(value, key);
|
||||
this.automat.actions.fetchSupplierArticleForSku(value, key);
|
||||
}
|
||||
|
||||
async handleCreateArticleForLine(line) {
|
||||
@@ -294,7 +334,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
// Keep the supplier SKU in the delivery line, but refresh supplier article data
|
||||
// using the supplier SKU (not the internal SKU)
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
||||
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
||||
|
||||
enqueueSnackbar(`Artikel "${internalSku}" erstellt und Lieferanten-Artikel "${supplierSku}" zugeordnet.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
@@ -303,70 +343,187 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenArticleSearchForLine(line) {
|
||||
this.setState({
|
||||
articleSearchDialogOpen: true,
|
||||
articleSearchLine: line,
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseArticleSearch() {
|
||||
this.setState({
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
});
|
||||
}
|
||||
|
||||
async handleAssignArticleForLine(article, line) {
|
||||
const { partyId } = this.state;
|
||||
if (!partyId) {
|
||||
enqueueSnackbar('Bitte wählen Sie zuerst einen Lieferanten aus.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const internalSku = article?.sku;
|
||||
if (!internalSku) {
|
||||
enqueueSnackbar('Artikel hat keine SKU.', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const supplierSku = line?.sku;
|
||||
|
||||
try {
|
||||
await this.context.article.addSupplier(internalSku, {
|
||||
entityId: partyId,
|
||||
sku: supplierSku || undefined,
|
||||
pu: line?.pu ? Number(line.pu) : undefined,
|
||||
stock: line?.amount ? Number(line.amount) : undefined,
|
||||
netPrice: line?.netprice ? Number(line.netprice) : undefined,
|
||||
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
|
||||
});
|
||||
|
||||
if (supplierSku) {
|
||||
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
||||
} else {
|
||||
this.automat.actions.fetchSupplierArticles(partyId);
|
||||
}
|
||||
|
||||
enqueueSnackbar(
|
||||
`Lieferant erfolgreich dem Artikel "${internalSku}" zugeordnet.`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
|
||||
this.setState({
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Zuordnen des Artikels:', err);
|
||||
notifyError(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoveSupplierArticle(line, supplierArticle) {
|
||||
const internalSku = supplierArticle?.article?.sku;
|
||||
const assignmentId = supplierArticle?.id;
|
||||
if (!internalSku || assignmentId == null) {
|
||||
enqueueSnackbar('Verknüpfung konnte nicht ermittelt werden.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.automat.actions.unlinkSupplierArticle(internalSku, assignmentId, line?.key);
|
||||
enqueueSnackbar(
|
||||
`Verknüpfung zu Artikel "${internalSku}" wurde entfernt.`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Lösen der Lieferanten-Verknüpfung:', err);
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
handleAddLine() {
|
||||
deliveryEditorAutomat.actions.addLineitem();
|
||||
this.automat.actions.addLineitem();
|
||||
}
|
||||
|
||||
handleRemoveLine(key) {
|
||||
const index = this.findLineIndex(key);
|
||||
deliveryEditorAutomat.actions.removeLineitem(index >= 0 ? index : key);
|
||||
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
||||
}
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
|
||||
// 1. Generate thumbnail for PDF
|
||||
let thumbnail = null;
|
||||
try {
|
||||
thumbnail = await generatePdfThumbnail(file);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
|
||||
}
|
||||
const isPdf =
|
||||
file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
|
||||
const isCsv =
|
||||
file.type === 'text/csv' || file.name?.toLowerCase().endsWith('.csv');
|
||||
|
||||
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const pdfAttachment = {
|
||||
id: pdfId,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: 'application/pdf',
|
||||
thumbnail,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
deliveryEditorAutomat.actions.addAttachment(pdfAttachment);
|
||||
if (isPdf) {
|
||||
// 1. Generate thumbnail for PDF
|
||||
let thumbnail = null;
|
||||
try {
|
||||
thumbnail = await generatePdfThumbnail(file);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
|
||||
}
|
||||
|
||||
// 2. Extract text using pdftotext-wasm to create 2nd file (textfile)
|
||||
try {
|
||||
const text = await extractPdfText(file, { layout: true });
|
||||
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
|
||||
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
|
||||
const txtAttachment = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file: txtFile,
|
||||
name: txtFile.name,
|
||||
size: txtFile.size,
|
||||
type: 'text/plain',
|
||||
text,
|
||||
parentPdfId: pdfId,
|
||||
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const pdfAttachment = {
|
||||
id: pdfId,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: 'application/pdf',
|
||||
thumbnail,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
deliveryEditorAutomat.actions.addAttachment(txtAttachment);
|
||||
} catch (txtErr) {
|
||||
console.error('Fehler beim Extrahieren von Text mit pdftotext-wasm:', txtErr);
|
||||
this.automat.actions.addAttachment(pdfAttachment);
|
||||
|
||||
// 2. Extract text using pdfconvert-wasm to create 2nd file (textfile)
|
||||
try {
|
||||
const text = await extractPdfText(file, { layout: true });
|
||||
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
|
||||
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
|
||||
const txtAttachment = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file: txtFile,
|
||||
name: txtFile.name,
|
||||
size: txtFile.size,
|
||||
type: 'text/plain',
|
||||
text,
|
||||
parentPdfId: pdfId,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
this.automat.actions.addAttachment(txtAttachment);
|
||||
} catch (txtErr) {
|
||||
console.error('Fehler beim Extrahieren von Text mit pdfconvert-wasm:', txtErr);
|
||||
}
|
||||
} else {
|
||||
// Direct text / CSV attachment
|
||||
let text = '';
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Lesen der Datei:', err);
|
||||
}
|
||||
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const textAttachment = {
|
||||
id: attachmentId,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: isCsv ? 'text/csv' : 'text/plain',
|
||||
text,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
this.automat.actions.addAttachment(textAttachment);
|
||||
}
|
||||
|
||||
// 3. Run SSE parsing with backend
|
||||
await this.runPdfParse(file);
|
||||
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
|
||||
await this.runParse(file);
|
||||
}
|
||||
|
||||
async handlePdfRescan(attachment) {
|
||||
if (!attachment?.file || this.state.pdfUploading) return;
|
||||
if (!attachment || this.state.pdfUploading) return;
|
||||
|
||||
let targetAttachment = attachment;
|
||||
if (!targetAttachment.file || targetAttachment.file.size === 0) {
|
||||
const file = await this.ensureAttachmentFile(targetAttachment);
|
||||
if (!file || file.size === 0) {
|
||||
enqueueSnackbar('Datei konnte nicht geladen werden', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
targetAttachment = { ...targetAttachment, file };
|
||||
}
|
||||
|
||||
// Refresh textfile upon rescan for PDF
|
||||
const isPdf = attachment.type === 'application/pdf' || attachment.name.toLowerCase().endsWith('.pdf');
|
||||
const isPdf = targetAttachment.type === 'application/pdf' || targetAttachment.name.toLowerCase().endsWith('.pdf');
|
||||
if (isPdf) {
|
||||
try {
|
||||
const text = await extractPdfText(attachment.file, { layout: true });
|
||||
const text = await extractPdfText(targetAttachment.file, { layout: true });
|
||||
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
|
||||
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
|
||||
|
||||
@@ -374,7 +531,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
(a) => a.parentPdfId === attachment.id || a.name === txtName,
|
||||
);
|
||||
if (existingTxt) {
|
||||
deliveryEditorAutomat.actions.updateAttachment({
|
||||
this.automat.actions.updateAttachment({
|
||||
id: existingTxt.id,
|
||||
file: txtFile,
|
||||
size: txtFile.size,
|
||||
@@ -382,7 +539,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
isDirty: true,
|
||||
});
|
||||
} else {
|
||||
deliveryEditorAutomat.actions.addAttachment({
|
||||
this.automat.actions.addAttachment({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file: txtFile,
|
||||
name: txtFile.name,
|
||||
@@ -403,19 +560,25 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
handlePdfDelete(attachment) {
|
||||
if (!attachment?.id) return;
|
||||
deliveryEditorAutomat.actions.removeAttachment(attachment.id);
|
||||
this.automat.actions.removeAttachment(attachment.id);
|
||||
}
|
||||
|
||||
async runParse(file) {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
this._parseAbortController = abortController;
|
||||
|
||||
this.setState({
|
||||
pdfUploading: true,
|
||||
pdfDialogOpen: true,
|
||||
pdfFileName: file.name,
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
|
||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
|
||||
});
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -428,8 +591,8 @@ class LieferungNeuPage extends PureComponent {
|
||||
const parser = this.context?.delivery?.parseSse;
|
||||
if (!parser) throw new Error('parseSse nicht verfügbar.');
|
||||
|
||||
for await (const { event, data } of parser(file)) {
|
||||
if (this._cancelled) return;
|
||||
for await (const { event, data } of parser(file, { signal: abortController.signal })) {
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
switch (event) {
|
||||
case 'status':
|
||||
appendMessage({ type: 'status', text: data.message ?? String(data) });
|
||||
@@ -487,9 +650,17 @@ class LieferungNeuPage extends PureComponent {
|
||||
const docType = result?.type ?? result?.basedata?.manifestType;
|
||||
const notes = Array.isArray(result?.notes) ? result.notes : [];
|
||||
|
||||
const parsedSupplierName = (
|
||||
result?.supplierName ??
|
||||
result?.basedata?.lieferantName ??
|
||||
result?.lieferantName ??
|
||||
result?.basedata?.supplierName ??
|
||||
''
|
||||
).trim();
|
||||
|
||||
// If supplierId is null, try matching supplier by name from loaded delivery entities
|
||||
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.state.deliveryEntities)) {
|
||||
const nameToMatch = result.basedata.lieferantName.trim().toLowerCase();
|
||||
if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
|
||||
const nameToMatch = parsedSupplierName.toLowerCase();
|
||||
const matched = this.state.deliveryEntities.find(
|
||||
(e) =>
|
||||
e.name &&
|
||||
@@ -500,24 +671,29 @@ class LieferungNeuPage extends PureComponent {
|
||||
if (matched) supplierId = matched.id;
|
||||
}
|
||||
|
||||
if (docDate) deliveryEditorAutomat.actions.setDate(docDate);
|
||||
if (supplierId != null) deliveryEditorAutomat.actions.setDeliveryEntity(supplierId);
|
||||
const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
|
||||
this.setState({ unmatchedSupplierName });
|
||||
this.automat.actions.setUnmatchedSupplierName(unmatchedSupplierName);
|
||||
|
||||
|
||||
if (docDate) this.automat.actions.setDate(docDate);
|
||||
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
|
||||
if (extNum != null) {
|
||||
deliveryEditorAutomat.actions.setExternalNumber(String(extNum));
|
||||
this.automat.actions.setExternalNumber(String(extNum));
|
||||
}
|
||||
if (docType) deliveryEditorAutomat.actions.setType(docType);
|
||||
if (docType) this.automat.actions.setType(docType);
|
||||
if (newLines && newLines.length > 0) {
|
||||
deliveryEditorAutomat.actions.setLineitems(newLines);
|
||||
this.automat.actions.setLineitems(newLines);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
deliveryEditorAutomat.actions.setNotes(notes);
|
||||
this.automat.actions.setNotes(notes);
|
||||
}
|
||||
|
||||
// Supply whole article list to GET /supplier/{id}/articles
|
||||
const effectiveSupplierId = supplierId ?? deliveryEditorAutomat.state.deliveryEntity ?? deliveryEditorAutomat.state.partyId;
|
||||
const effectiveLines = (newLines && newLines.length > 0) ? newLines : deliveryEditorAutomat.state.lines;
|
||||
const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
|
||||
const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
|
||||
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
|
||||
this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -536,6 +712,17 @@ class LieferungNeuPage extends PureComponent {
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
if (abortController.signal.aborted || err.name === 'AbortError') {
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
@@ -544,6 +731,9 @@ class LieferungNeuPage extends PureComponent {
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
if (this._parseAbortController === abortController) {
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (!this._cancelled) {
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
@@ -563,28 +753,157 @@ class LieferungNeuPage extends PureComponent {
|
||||
await this.runParse(file);
|
||||
}
|
||||
|
||||
handleAbortParse() {
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
pdfDialogData: prev.pdfDialogData
|
||||
? {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
aborted: true,
|
||||
error: 'Analyse abgebrochen.',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
handleClosePdfDialog() {
|
||||
this.setState({ pdfDialogOpen: false });
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
this.setState({ pdfDialogOpen: false, pdfUploading: false });
|
||||
}
|
||||
|
||||
async ensureAttachmentFile(attachment) {
|
||||
if (!attachment) return null;
|
||||
const hasContent =
|
||||
(attachment.file instanceof Blob || attachment.file instanceof File) &&
|
||||
attachment.file.size > 0;
|
||||
if (hasContent) {
|
||||
return attachment.file;
|
||||
}
|
||||
|
||||
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (docId == null || fileId == null) {
|
||||
return attachment.file || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const getFile =
|
||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
||||
deliveryApi.getFile;
|
||||
const blob = await getFile(docId, fileId);
|
||||
const filename = attachment.name || 'Dokument';
|
||||
const mimetype = attachment.type || blob.type || 'application/octet-stream';
|
||||
const file = new File([blob], filename, { type: mimetype });
|
||||
|
||||
let text = attachment.text;
|
||||
const isPdf =
|
||||
mimetype === 'application/pdf' || filename.toLowerCase().endsWith('.pdf');
|
||||
const isText =
|
||||
!isPdf &&
|
||||
(mimetype.startsWith('text/') ||
|
||||
filename.toLowerCase().endsWith('.txt') ||
|
||||
filename.toLowerCase().endsWith('.csv'));
|
||||
|
||||
if (isText && typeof text !== 'string') {
|
||||
try {
|
||||
text = await blob.text();
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Dekodieren des Texts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.automat.actions.updateAttachment({
|
||||
id: attachment.id,
|
||||
file,
|
||||
size: blob.size,
|
||||
...(typeof text === 'string' ? { text } : {}),
|
||||
});
|
||||
|
||||
return file;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Dateiinhalte:', err);
|
||||
notifyError(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
handleTextEditorContentLoaded(attachment, text, blob) {
|
||||
const file = new File([blob], attachment.name || 'Dokument.txt', {
|
||||
type: attachment.type || 'text/plain;charset=utf-8',
|
||||
});
|
||||
this.automat.actions.updateAttachment({
|
||||
id: attachment.id,
|
||||
file,
|
||||
size: blob.size,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
handleAttachmentClick(attachment) {
|
||||
if (!attachment) return;
|
||||
const isPdf =
|
||||
attachment.type === 'application/pdf' ||
|
||||
attachment.name?.toLowerCase().endsWith('.pdf');
|
||||
const isText =
|
||||
attachment.type?.startsWith('text/') ||
|
||||
attachment.name?.toLowerCase().endsWith('.txt') ||
|
||||
Boolean(attachment.text);
|
||||
!isPdf &&
|
||||
(attachment.type?.startsWith('text/') ||
|
||||
attachment.name?.toLowerCase().endsWith('.txt') ||
|
||||
attachment.name?.toLowerCase().endsWith('.csv'));
|
||||
|
||||
if (isText) {
|
||||
this.setState({
|
||||
textEditorOpen: true,
|
||||
textEditorAttachment: attachment,
|
||||
});
|
||||
} else if (attachment.file instanceof Blob || attachment.file instanceof File) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
if (
|
||||
(attachment.file instanceof Blob || attachment.file instanceof File) &&
|
||||
attachment.file.size > 0
|
||||
) {
|
||||
const url = URL.createObjectURL(attachment.file);
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (docId != null && fileId != null) {
|
||||
window.open(
|
||||
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
|
||||
'_blank',
|
||||
);
|
||||
void this.ensureAttachmentFile(attachment);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.file instanceof Blob || attachment.file instanceof File) {
|
||||
if (attachment.file.size > 0) {
|
||||
const url = URL.createObjectURL(attachment.file);
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
if (docId != null && fileId != null) {
|
||||
window.open(
|
||||
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseTextEditor() {
|
||||
@@ -602,7 +921,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
});
|
||||
|
||||
deliveryEditorAutomat.actions.updateAttachment({
|
||||
this.automat.actions.updateAttachment({
|
||||
id: attachment.id,
|
||||
file: newFile,
|
||||
size: newFile.size,
|
||||
@@ -654,7 +973,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
await this.context[cfg.updateCache]();
|
||||
if (this._cancelled) return;
|
||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||
deliveryEditorAutomat.actions.resetDelivery();
|
||||
this.automat.actions.resetDelivery();
|
||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||
} else {
|
||||
const data = await this.context[cfg.api].update(docId, body);
|
||||
@@ -667,17 +986,17 @@ class LieferungNeuPage extends PureComponent {
|
||||
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
||||
const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
|
||||
|
||||
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
|
||||
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
|
||||
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
|
||||
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
|
||||
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
|
||||
deliveryEditorAutomat.actions.setLineitems(lines);
|
||||
this.automat.actions.setId(doc.id ?? docId);
|
||||
this.automat.actions.setDate(dateInputValue(doc.date));
|
||||
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
|
||||
if (doc.number != null) this.automat.actions.setNumber(doc.number);
|
||||
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
|
||||
this.automat.actions.setLineitems(lines);
|
||||
|
||||
// Restore attachments with thumbnails
|
||||
const apiFiles = doc.files ?? [];
|
||||
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
|
||||
deliveryEditorAutomat.actions.setAttachments(attachments);
|
||||
this.automat.actions.setAttachments(attachments);
|
||||
|
||||
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
|
||||
this.setState({ saving: false });
|
||||
@@ -750,16 +1069,50 @@ class LieferungNeuPage extends PureComponent {
|
||||
/>
|
||||
{partyId == null && (!lines || lines.length === 0) ? (
|
||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
||||
<Typography color="text.secondary">
|
||||
{cfg.partyGateMessage}
|
||||
</Typography>
|
||||
{this.state.unmatchedSupplierName ? (
|
||||
<Stack spacing={2} sx={{ p: 1 }}>
|
||||
<Typography color="text.secondary">
|
||||
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
||||
>
|
||||
Lieferant „{this.state.unmatchedSupplierName}“ anlegen
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary">
|
||||
{cfg.partyGateMessage}
|
||||
</Typography>
|
||||
)}
|
||||
</PanelTile>
|
||||
) : (
|
||||
<>
|
||||
{partyId == null && lines && lines.length > 0 && (
|
||||
{partyId == null && (
|
||||
<PanelTile wide={3} color="#fef3c7">
|
||||
<Alert severity="warning" sx={{ py: 0.5 }}>
|
||||
Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={
|
||||
this.state.unmatchedSupplierName ? (
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
||||
>
|
||||
Lieferant anlegen
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
sx={{ py: 0.5, alignItems: 'center' }}
|
||||
>
|
||||
{this.state.unmatchedSupplierName
|
||||
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
|
||||
: 'Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.'}
|
||||
</Alert>
|
||||
</PanelTile>
|
||||
)}
|
||||
@@ -778,6 +1131,9 @@ class LieferungNeuPage extends PureComponent {
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onSave={() => void this.handleSave()}
|
||||
onCreateArticle={this.handleCreateArticleForLine}
|
||||
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
|
||||
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -800,13 +1156,25 @@ class LieferungNeuPage extends PureComponent {
|
||||
open={pdfDialogOpen}
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
unmatchedSupplierName={this.state.unmatchedSupplierName}
|
||||
onCreateSupplier={this.handleCreateSupplierFromPdf}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
|
||||
<TextEditorDialog
|
||||
open={this.state.textEditorOpen}
|
||||
docId={this.props.docId ?? this.state.id}
|
||||
attachment={this.state.textEditorAttachment}
|
||||
onClose={this.handleCloseTextEditor}
|
||||
onSave={this.handleSaveTextEditor}
|
||||
onContentLoad={this.handleTextEditorContentLoaded}
|
||||
/>
|
||||
<ArticleSearchDialog
|
||||
open={this.state.articleSearchDialogOpen}
|
||||
line={this.state.articleSearchLine}
|
||||
onClose={this.handleCloseArticleSearch}
|
||||
onAssign={this.handleAssignArticleForLine}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
124
src/store/articleAutomat.js
Normal file
124
src/store/articleAutomat.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { brand as brandApi } from '../api';
|
||||
import authAutomat, {
|
||||
AUTH_COMPANY_CHANGED,
|
||||
AUTH_LOGGED_OUT,
|
||||
AUTH_SESSION_ESTABLISHED,
|
||||
} from '../automats/authAutomat';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
import { catchBackendError } from '../utils/backendError';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
|
||||
function sortBrands(brands) {
|
||||
return [...brands].sort((a, b) =>
|
||||
@@ -71,9 +69,8 @@ const brandSlice = createSlice({
|
||||
row,
|
||||
]);
|
||||
})
|
||||
.addCase(selectCompany.fulfilled, () => initialState)
|
||||
.addCase(createCompany.fulfilled, () => initialState)
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
|
||||
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,12 +81,9 @@ export const selectBrandsError = (state) => state.brand.error;
|
||||
export function brandLoadListener(listenerMiddleware) {
|
||||
listenerMiddleware.startListening({
|
||||
matcher: (action) =>
|
||||
bootstrapSession.fulfilled.match(action) ||
|
||||
loginWithGoogle.fulfilled.match(action) ||
|
||||
selectCompany.fulfilled.match(action) ||
|
||||
createCompany.fulfilled.match(action),
|
||||
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||
effect: async (_action, api) => {
|
||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId) {
|
||||
api.dispatch(loadBrands());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { category, article } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
||||
import { logout } from './authSlice';
|
||||
import { AUTH_LOGGED_OUT } from '../automats/authAutomat';
|
||||
import { loadCurrentCompany } from './companySlice';
|
||||
|
||||
export const ROOT_CATEGORY_NAME = 'Home';
|
||||
@@ -645,7 +645,7 @@ const categorySlice = createSlice({
|
||||
};
|
||||
}
|
||||
})
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { company } from '../api';
|
||||
import authAutomat, {
|
||||
AUTH_COMPANY_CHANGED,
|
||||
AUTH_LOGGED_OUT,
|
||||
AUTH_SESSION_ESTABLISHED,
|
||||
} from '../automats/authAutomat';
|
||||
import { normalizeCompany } from '../utils/companyForm';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
|
||||
export const loadCurrentCompany = createAsyncThunk(
|
||||
'company/loadCurrent',
|
||||
async (_, { getState }) => {
|
||||
const companyId = getState().auth.user?.selectedCompanyId;
|
||||
async () => {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (!companyId) return null;
|
||||
const data = await company.getById(companyId);
|
||||
return normalizeCompany(data.company);
|
||||
@@ -21,8 +19,8 @@ export const loadCurrentCompany = createAsyncThunk(
|
||||
|
||||
export const updateCompany = createAsyncThunk(
|
||||
'company/update',
|
||||
async (payload, { getState }) => {
|
||||
const companyId = getState().auth.user?.selectedCompanyId;
|
||||
async (payload) => {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
|
||||
await company.update(companyId, payload);
|
||||
const data = await company.getById(companyId);
|
||||
@@ -66,7 +64,7 @@ const companySlice = createSlice({
|
||||
state.status = 'failed';
|
||||
state.error = action.error.message;
|
||||
})
|
||||
.addCase(logout.fulfilled, (state) => {
|
||||
.addCase(AUTH_LOGGED_OUT, (state) => {
|
||||
state.current = null;
|
||||
state.status = 'idle';
|
||||
state.error = null;
|
||||
@@ -87,12 +85,9 @@ export const selectCompanyRootCategoryId = (state) => state.company.current?.roo
|
||||
export function companyLoadListener(listenerMiddleware) {
|
||||
listenerMiddleware.startListening({
|
||||
matcher: (action) =>
|
||||
bootstrapSession.fulfilled.match(action) ||
|
||||
loginWithGoogle.fulfilled.match(action) ||
|
||||
selectCompany.fulfilled.match(action) ||
|
||||
createCompany.fulfilled.match(action),
|
||||
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||
effect: async (_action, api) => {
|
||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId) {
|
||||
api.dispatch(loadCurrentCompany());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import authReducer from './authSlice';
|
||||
import { setAuthEventDispatcher } from '../automats/authAutomat';
|
||||
import brandReducer, { brandLoadListener } from './brandSlice';
|
||||
import categoryReducer from './categorySlice';
|
||||
import companyReducer, { companyLoadListener } from './companySlice';
|
||||
@@ -25,16 +25,10 @@ function reduxDevLogger() {
|
||||
) {
|
||||
return next(action);
|
||||
}
|
||||
const before = storeApi.getState();
|
||||
const result = next(action);
|
||||
const after = storeApi.getState();
|
||||
console.log('[redux]', action.type, {
|
||||
payload: action.payload,
|
||||
auth: {
|
||||
selectedCompanyId: after.auth.user?.selectedCompanyId ?? null,
|
||||
needsCompanySetup:
|
||||
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
||||
},
|
||||
company: after.company.current,
|
||||
brands: after.brand.brands.length,
|
||||
posterminals: after.posterminal.terminals.length,
|
||||
@@ -46,7 +40,6 @@ function reduxDevLogger() {
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
company: companyReducer,
|
||||
category: categoryReducer,
|
||||
brand: brandReducer,
|
||||
@@ -58,3 +51,5 @@ export const store = configureStore({
|
||||
.concat(listenerMiddleware.middleware)
|
||||
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
|
||||
});
|
||||
|
||||
setAuthEventDispatcher(store.dispatch);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { posterminal } from '../api';
|
||||
import authAutomat, {
|
||||
AUTH_COMPANY_CHANGED,
|
||||
AUTH_LOGGED_OUT,
|
||||
AUTH_SESSION_ESTABLISHED,
|
||||
} from '../automats/authAutomat';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
import { loadCurrentCompany } from './companySlice';
|
||||
|
||||
/**
|
||||
@@ -236,7 +234,7 @@ const posterminalSlice = createSlice({
|
||||
}
|
||||
})
|
||||
// Reset on logout
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,12 +247,9 @@ export const { resetPosterminalState } = posterminalSlice.actions;
|
||||
export function posterminalLoadListener(listenerMiddleware) {
|
||||
listenerMiddleware.startListening({
|
||||
matcher: (action) =>
|
||||
bootstrapSession.fulfilled.match(action) ||
|
||||
loginWithGoogle.fulfilled.match(action) ||
|
||||
selectCompany.fulfilled.match(action) ||
|
||||
createCompany.fulfilled.match(action),
|
||||
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||
effect: async (action, api) => {
|
||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
console.log('[posterminalLoadListener]', action.type, { companyId });
|
||||
if (companyId) {
|
||||
api.dispatch(loadPosterminals());
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { unit as unitApi } from '../api';
|
||||
import authAutomat, {
|
||||
AUTH_COMPANY_CHANGED,
|
||||
AUTH_LOGGED_OUT,
|
||||
AUTH_SESSION_ESTABLISHED,
|
||||
} from '../automats/authAutomat';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
|
||||
function sortUnits(units) {
|
||||
return [...units].sort((a, b) => {
|
||||
@@ -69,9 +67,8 @@ const unitSlice = createSlice({
|
||||
.addCase(deleteUnit.fulfilled, (state, action) => {
|
||||
state.units = state.units.filter((u) => u.si !== action.payload);
|
||||
})
|
||||
.addCase(selectCompany.fulfilled, () => initialState)
|
||||
.addCase(createCompany.fulfilled, () => initialState)
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
|
||||
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,12 +79,9 @@ export const selectUnitsError = (state) => state.unit.error;
|
||||
export function unitLoadListener(listenerMiddleware) {
|
||||
listenerMiddleware.startListening({
|
||||
matcher: (action) =>
|
||||
bootstrapSession.fulfilled.match(action) ||
|
||||
loginWithGoogle.fulfilled.match(action) ||
|
||||
selectCompany.fulfilled.match(action) ||
|
||||
createCompany.fulfilled.match(action),
|
||||
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||
effect: async (_action, api) => {
|
||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId) {
|
||||
api.dispatch(loadUnits());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { pdfToText } from 'pdftotext-wasm';
|
||||
import { pdfToText, splitPdfPages } from 'pdfconvert-wasm';
|
||||
|
||||
export async function extractPdfText(pdfSource, options = { layout: true }) {
|
||||
return pdfToText(pdfSource, options);
|
||||
}
|
||||
|
||||
export { pdfToText };
|
||||
export { pdfToText, splitPdfPages };
|
||||
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
// Tells Vite/Webpack to emit the worker script as an asset file locally
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
import { pdfToPng } from 'pdfconvert-wasm';
|
||||
|
||||
export async function generatePdfThumbnail(pdfSource, targetWidth = 200) {
|
||||
let loadingTask;
|
||||
if (pdfSource instanceof File || pdfSource instanceof Blob) {
|
||||
const arrayBuffer = await pdfSource.arrayBuffer();
|
||||
loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
} else {
|
||||
loadingTask = pdfjsLib.getDocument(pdfSource);
|
||||
const images = await pdfToPng(pdfSource, {
|
||||
firstPage: 1,
|
||||
singleFile: true,
|
||||
scaleToX: targetWidth,
|
||||
});
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pdf = await loadingTask.promise;
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
const unscaledViewport = page.getViewport({ scale: 1 });
|
||||
const scale = targetWidth / unscaledViewport.width;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
}).promise;
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.85);
|
||||
const blob = new Blob([images[0].data], { type: 'image/png' });
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user