Compare commits
442 Commits
dd13f736c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283ce3dc5f | ||
|
|
863422df8e | ||
|
|
9841002398 | ||
|
|
bc11a2c3ab | ||
|
|
ec9f2d9816 | ||
|
|
bc284c6d42 | ||
|
|
0181e08657 | ||
|
|
6071e59d30 | ||
|
|
e7103ac340 | ||
|
|
9c6d5d79e4 | ||
|
|
7c33c097d4 | ||
|
|
76e911ebaf | ||
|
|
d2139fee45 | ||
|
|
8a416ce384 | ||
|
|
7fe9f90f2c | ||
|
|
456389be1b | ||
|
|
e788c960d5 | ||
|
|
b7046d3bc6 | ||
|
|
fcaf3d5821 | ||
|
|
ca28f56f3f | ||
|
|
545eb98191 | ||
|
|
ee80646c1c | ||
|
|
df015da8fc | ||
|
|
2d1a7364f2 | ||
|
|
f6eb31b70d | ||
|
|
6719cf9d51 | ||
|
|
cdb031921b | ||
|
|
9cb82745e3 | ||
|
|
56d376c9bd | ||
|
|
72f168f188 | ||
|
|
eed979bf11 | ||
|
|
28d6fece46 | ||
|
|
1b60965cea | ||
|
|
d3e16fd2b7 | ||
|
|
3bb861907e | ||
|
|
6ca7597c59 | ||
|
|
05f1311892 | ||
|
|
dba9ae5cf0 | ||
|
|
8b680b5a1a | ||
|
|
f555a1c9dd | ||
|
|
867d550814 | ||
|
|
afb33cde38 | ||
|
|
5fcc5670d9 | ||
|
|
6851e7c16f | ||
|
|
6e9d706bd6 | ||
|
|
d21def186e | ||
|
|
6d299c1f43 | ||
|
|
87ef79bea3 | ||
|
|
2e69f49e4d | ||
|
|
003b198b86 | ||
|
|
b13d994058 | ||
|
|
550586f46e | ||
|
|
3c51de06c2 | ||
|
|
e45f35e97b | ||
|
|
0453680c59 | ||
|
|
23e4a0951c | ||
|
|
96d0ae6742 | ||
|
|
fab291f68e | ||
|
|
481ea16a6a | ||
|
|
75691e6afc | ||
|
|
f52ab1be0c | ||
|
|
13e1821355 | ||
|
|
eeb4b92a79 | ||
|
|
3801ac4e52 | ||
|
|
31fdc30c5a | ||
|
|
bf39f4d017 | ||
|
|
2f946442c8 | ||
|
|
35144c7187 | ||
|
|
5dd45916b8 | ||
|
|
b9bd0380cb | ||
|
|
57347bdef9 | ||
|
|
778840ec5e | ||
|
|
82f00ad593 | ||
|
|
985f4a3da2 | ||
|
|
a32ea19b5e | ||
|
|
a2103b9d0b | ||
|
|
ae245af6d7 | ||
|
|
fbf107843f | ||
|
|
b09100b13e | ||
|
|
b9abe8ec55 | ||
|
|
3d571a988a | ||
|
|
18fa62514a | ||
|
|
07206674c1 | ||
|
|
70c7d5eff2 | ||
|
|
4f367ee943 | ||
|
|
c77297af72 | ||
|
|
9bc6d55e85 | ||
|
|
65c908f44e | ||
|
|
42241ea541 | ||
|
|
b727e00928 | ||
|
|
067207c58e | ||
|
|
90ee11fefb | ||
|
|
c86c6a84f5 | ||
|
|
e953bf4c8c | ||
|
|
935e01cb30 | ||
|
|
883c856bdb | ||
|
|
3cec22e4e5 | ||
|
|
c84a504a5c | ||
|
|
06d0dd3bb2 | ||
|
|
7286cd8bf2 | ||
|
|
7499fbc36e | ||
|
|
8961d662e5 | ||
|
|
c647068067 | ||
|
|
c77f367216 | ||
|
|
578387adca | ||
|
|
037a2b0ae4 | ||
|
|
eb15bcb83b | ||
|
|
9b40d1db87 | ||
|
|
f7ee9182ec | ||
|
|
479cac85a5 | ||
|
|
0a9deac0da | ||
|
|
abd40ff4fe | ||
|
|
5faea634ba | ||
|
|
35cfea91f8 | ||
|
|
9f9310e1ff | ||
|
|
c211040cd3 | ||
|
|
ac9034685f | ||
|
|
328ba164ec | ||
|
|
180f65f4d6 | ||
|
|
94f385e753 | ||
|
|
587170ce9b | ||
|
|
199bf869a6 | ||
|
|
596d0dc6f1 | ||
|
|
c15fa0a53e | ||
|
|
9cb93bddeb | ||
|
|
b42b63a0af | ||
|
|
cf85ec102d | ||
|
|
5644678b7d | ||
|
|
3df8c5e9c7 | ||
|
|
ea52753ef9 | ||
|
|
33684fc860 | ||
|
|
1b4b31b016 | ||
|
|
dc73905a19 | ||
|
|
ab9d5fe413 | ||
|
|
0a59a0987a | ||
|
|
691854fdd4 | ||
|
|
666d84b9f3 | ||
|
|
b8f226bd39 | ||
|
|
0d11dd5014 | ||
|
|
d1ccbf335c | ||
|
|
c332be8916 | ||
|
|
7080382f56 | ||
|
|
45c1067d9f | ||
|
|
582bf04476 | ||
|
|
c0ae79ba6b | ||
|
|
01d17ebb25 | ||
|
|
20d83b00fc | ||
|
|
54066fba7f | ||
|
|
b3051953b0 | ||
|
|
cb2f6ad37f | ||
|
|
bea427f3df | ||
|
|
bd16df64a1 | ||
|
|
6df833b362 | ||
|
|
e63abfe2c0 | ||
|
|
d729423249 | ||
|
|
e031458a42 | ||
|
|
bb22da7aae | ||
|
|
0b0ffb2203 | ||
|
|
b3a81efe46 | ||
|
|
82db75ac0f | ||
|
|
5d8ac3a8eb | ||
|
|
0851c31970 | ||
|
|
d8c1d44652 | ||
|
|
23a198b0c1 | ||
|
|
7ad8a3a301 | ||
|
|
4a7a02a0a4 | ||
|
|
8e098b145e | ||
|
|
7a4d452109 | ||
|
|
99d95ee234 | ||
|
|
f5a5cb51e3 | ||
|
|
52411ac45d | ||
|
|
5c57097ad0 | ||
|
|
a592cc30f6 | ||
|
|
764d6f850a | ||
|
|
d851c6e1e9 | ||
|
|
7bc1da4de6 | ||
|
|
ff165799d4 | ||
|
|
a52366c477 | ||
|
|
2b2b5b945d | ||
|
|
fafeec93b6 | ||
|
|
beed294cb0 | ||
|
|
8e783a571f | ||
|
|
7e5b262d75 | ||
|
|
47bac6024c | ||
|
|
657d184d1c | ||
|
|
a90776bb8b | ||
|
|
670184c633 | ||
|
|
7114258a96 | ||
|
|
40bf70a41c | ||
|
|
37acaf9554 | ||
|
|
9817d4abda | ||
|
|
954f276404 | ||
|
|
77f6a0815f | ||
|
|
360660b850 | ||
|
|
6abadac120 | ||
|
|
bcd61fcf5e | ||
|
|
6e0b72b34b | ||
|
|
39aa898324 | ||
|
|
7ce2e3216f | ||
|
|
002f63a47a | ||
|
|
ed7bb80006 | ||
|
|
e2a2d02cb4 | ||
|
|
eb4654dfbf | ||
|
|
c62834833d | ||
|
|
fdb67b7221 | ||
|
|
c76777d0b8 | ||
|
|
c54d932e36 | ||
|
|
a0d71f70b7 | ||
|
|
b8fdef4e11 | ||
|
|
4188d8f46f | ||
|
|
49f8f2f93f | ||
|
|
61dd2f3a5d | ||
|
|
62fa58e4ec | ||
|
|
e7b3044903 | ||
|
|
61076a9ef2 | ||
|
|
ee3012670f | ||
|
|
937f434641 | ||
|
|
424609a0f6 | ||
|
|
2dd6533481 | ||
|
|
c0600ecb3b | ||
|
|
56a86fc005 | ||
|
|
f6cb3d3e35 | ||
|
|
e2bf08d340 | ||
|
|
bf7ff349f3 | ||
|
|
bc583a180f | ||
|
|
345d6f5752 | ||
|
|
cd530ed448 | ||
|
|
94fcac0795 | ||
|
|
8f68f7d224 | ||
|
|
d715b9c4e5 | ||
|
|
4c5ccf7f64 | ||
|
|
1ad9b14aa6 | ||
|
|
df22f77369 | ||
|
|
21663e4681 | ||
|
|
6b0839639d | ||
|
|
2cfa263044 | ||
|
|
858e413627 | ||
|
|
a0a40b243a | ||
|
|
15dce3c1cf | ||
|
|
627868eb2e | ||
|
|
d5d45e57d4 | ||
|
|
59c3b901ae | ||
|
|
afff1a948c | ||
|
|
0a073f7cf4 | ||
|
|
ed2ac2c2c3 | ||
|
|
d0fed9a2d1 | ||
|
|
36095d66c9 | ||
|
|
cc914085fc | ||
|
|
d86af21125 | ||
|
|
a96a80a036 | ||
|
|
95523ce9f3 | ||
|
|
31467f106a | ||
|
|
1e2f57e941 | ||
|
|
e8b2ead83f | ||
|
|
5ca1644d0c | ||
|
|
83dc698322 | ||
|
|
5e8037d430 | ||
|
|
45cc69730b | ||
|
|
deb22d8b4f | ||
|
|
3032299257 | ||
|
|
240a5dd3d7 | ||
|
|
e9d8e79cf1 | ||
|
|
c75521dc25 | ||
|
|
3379a63baf | ||
|
|
ce072a3731 | ||
|
|
78994730d1 | ||
|
|
9d578d37f8 | ||
|
|
d32e790540 | ||
|
|
d5aca2b183 | ||
|
|
0dbc7e2f65 | ||
|
|
1833fbd631 | ||
|
|
1a9f77264f | ||
|
|
cef9c4da4f | ||
|
|
a20668cd61 | ||
|
|
004e0de869 | ||
|
|
6af150aea4 | ||
|
|
9bc878eb51 | ||
|
|
f778b2d104 | ||
|
|
454b802943 | ||
|
|
909898a3c7 | ||
|
|
c5b46f11f1 | ||
|
|
859fec808d | ||
|
|
f4ad1763f0 | ||
|
|
4c0691924e | ||
|
|
ff54fe3a18 | ||
|
|
2c36a705c4 | ||
|
|
f2f60d5766 | ||
|
|
86069fa54c | ||
|
|
082771b9c3 | ||
|
|
d04a5f104f | ||
|
|
9a2419e115 | ||
|
|
782bc17bd1 | ||
|
|
8a042ef9f2 | ||
|
|
ac677dd5e3 | ||
|
|
2800fb64e0 | ||
|
|
9b47190d47 | ||
|
|
1bbeeaeff4 | ||
|
|
14ae017b64 | ||
|
|
645a9153a0 | ||
|
|
e9190f1ea9 | ||
|
|
5e62be468f | ||
|
|
56274ecb1d | ||
|
|
5f7db9adf5 | ||
|
|
c0b436f9f2 | ||
|
|
8285936bc9 | ||
|
|
38339b13a2 | ||
|
|
1933ac794d | ||
|
|
3cf0e932b2 | ||
|
|
ee93f71b27 | ||
|
|
22063e6944 | ||
|
|
056e4f63b6 | ||
|
|
6578f7d1ac | ||
|
|
f91c50ef69 | ||
|
|
7f988c9d63 | ||
|
|
064dd2bd31 | ||
|
|
457c41ab1d | ||
|
|
40416cdae5 | ||
|
|
ca4033b626 | ||
|
|
90c502071a | ||
|
|
fa6d910723 | ||
|
|
930bd33072 | ||
|
|
e93e61cfcf | ||
|
|
b165f86811 | ||
|
|
30b573cc63 | ||
|
|
14318cd9c6 | ||
|
|
3658d76c43 | ||
|
|
32325eb4aa | ||
|
|
be02e531c5 | ||
|
|
7829addbdd | ||
|
|
ec238c7b50 | ||
|
|
b01ebccf90 | ||
|
|
ab2e8c92fb | ||
|
|
243eba5fc6 | ||
|
|
8bc912c567 | ||
|
|
cba59532ef | ||
|
|
878f558a3a | ||
|
|
3d1601595e | ||
|
|
d49ec77043 | ||
|
|
ee5a30bb21 | ||
|
|
7bc7c827fb | ||
|
|
79e67f2be6 | ||
|
|
4cc8c49cf1 | ||
|
|
ee2056a159 | ||
|
|
8f46e59f08 | ||
|
|
7045fe5e69 | ||
|
|
6c0b1503e8 | ||
|
|
e4927f5d8a | ||
|
|
92d3d1f5ed | ||
|
|
22115cb583 | ||
|
|
a2c18841c1 | ||
|
|
543382c3e1 | ||
|
|
1b5d8efbc3 | ||
|
|
b99b39f640 | ||
|
|
cbf669f640 | ||
|
|
4769a87c54 | ||
|
|
03608017f3 | ||
|
|
ee49ed29de | ||
|
|
186a9c8ad1 | ||
|
|
9a5d90a99f | ||
|
|
12db70e1ae | ||
|
|
858dfe1280 | ||
|
|
fdb43ecfd8 | ||
|
|
96f8da8c64 | ||
|
|
4445bc259e | ||
|
|
2daeb7ebbf | ||
|
|
dd011a26f2 | ||
|
|
1c6f4e6706 | ||
|
|
863ff20733 | ||
|
|
0a1002227c | ||
|
|
f627ff5b5a | ||
|
|
5c48d1be65 | ||
|
|
bac2d9a202 | ||
|
|
81620521e7 | ||
|
|
26bee398a6 | ||
|
|
358d34ecab | ||
|
|
8c5419c89e | ||
|
|
5b4cf1683a | ||
|
|
8010436157 | ||
|
|
4d653304cd | ||
|
|
b766769634 | ||
|
|
81b59fd309 | ||
|
|
a90069d340 | ||
|
|
8b401fa50b | ||
|
|
f765b87336 | ||
|
|
669954b496 | ||
|
|
9146ebaba1 | ||
|
|
874d6c010f | ||
|
|
dd8566eabc | ||
|
|
da318223c1 | ||
|
|
4edd417dab | ||
|
|
f2fce9097b | ||
|
|
0d0c5dfdfb | ||
|
|
c79ece307c | ||
|
|
bc4a1e0468 | ||
|
|
be111e8f7c | ||
|
|
20e53fee04 | ||
|
|
8900c7a58c | ||
|
|
6ea4f8fb36 | ||
|
|
359efc6481 | ||
|
|
d6dea21f22 | ||
|
|
2f2906b8b5 | ||
|
|
d8f4727f32 | ||
|
|
1b9c39e975 | ||
|
|
43e1ab98b7 | ||
|
|
4cb050c285 | ||
|
|
ada455ea4f | ||
|
|
88c96c94a3 | ||
|
|
447033e53a | ||
|
|
18419b1615 | ||
|
|
c4f8ebb41b | ||
|
|
0daf9fef3d | ||
|
|
74e932c5cf | ||
|
|
f9383e717f | ||
|
|
0f875f347e | ||
|
|
559307675c | ||
|
|
3ab9d904e6 | ||
|
|
454c5ab6bf | ||
|
|
dcca972ff8 | ||
|
|
e5b43361cc | ||
|
|
a3db1742e7 | ||
|
|
b331f8f535 | ||
|
|
0391e845f0 | ||
|
|
c21a951884 | ||
|
|
b7fdbb3011 | ||
|
|
ab65fb4776 | ||
|
|
c94ce20b7a | ||
|
|
49057dc0d6 | ||
|
|
3ae840b2fe | ||
|
|
8aae27bb87 | ||
|
|
51d01a502b | ||
|
|
5e13b8f9cd | ||
|
|
536e7b1e2a | ||
|
|
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.
|
||||
1315
.agents/skills/backend/SKILL.md
Normal file
1315
.agents/skills/backend/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
20
.agents/skills/coding-style/SKILL.md
Normal file
20
.agents/skills/coding-style/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: coding-style
|
||||
description: >-
|
||||
Coding and project style conventions for the Dashfront repository.
|
||||
---
|
||||
|
||||
class componets disconnected from state, in automat.
|
||||
|
||||
Reference:
|
||||
Component: src/routes/artikel/marken.jsx
|
||||
it's Automat: src/automats/brandAutomat.js
|
||||
|
||||
|
||||
|
||||
use vs code region markers in code files
|
||||
use:
|
||||
// MARK: calls startApi from ./api/index.js
|
||||
atop important functions
|
||||
|
||||
don't use tooltips
|
||||
82
.agents/skills/frontend-routes/SKILL.md
Normal file
82
.agents/skills/frontend-routes/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: frontend-routes
|
||||
description: >-
|
||||
Guide and reference for constructing valid client-side frontend routes in Dashfront.
|
||||
Use this skill whenever an agent needs to navigate the user to specific pages, entities, or views.
|
||||
---
|
||||
|
||||
# Dashfront Frontend Routes
|
||||
|
||||
Reference guide for the backend LLM agent when emitting route navigation actions (e.g. redirecting or deep-linking the user).
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick Route Construction Matrix
|
||||
|
||||
| Destination | Path Pattern | Parameters / Construction | Example |
|
||||
|:---|:---|:---|:---|
|
||||
| **Article Search / Index** | `/artikel` | Query params: `?target={query}&filter={filter}&offset={n}` | `/artikel?target=kabel` |
|
||||
| **Article Detail** | `/artikel/:sku` | Encode SKU: `/artikel/${encodeURIComponent(sku)}` | `/artikel/ART-10042` |
|
||||
| **Warehouse / Stock** | `/artikel/lager` | Static | `/artikel/lager` |
|
||||
| **Brands** | `/artikel/marken` | Static | `/artikel/marken` |
|
||||
| **Label Templates** | `/artikel/vorlagen` | Static | `/artikel/vorlagen` |
|
||||
| **Attributes & Features** | `/artikel/merkmale` | Static | `/artikel/merkmale` |
|
||||
| **Category Tree Root** | `/kategorien` | Static | `/kategorien` |
|
||||
| **Category Subtree** | `/kategorien/*` | Join encoded slugs: `/kategorien/${slug1}/${slug2}` | `/kategorien/elektronik/kabel` |
|
||||
| **Delivery List** | `/lieferung` | Static | `/lieferung` |
|
||||
| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` |
|
||||
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
|
||||
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
|
||||
| **Order List (Auftrag / Bestellungen)** | `/auftrag` | Static (legacy `/auftrag/bestellung` redirects here) | `/auftrag` |
|
||||
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
|
||||
| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` |
|
||||
| **Quote List (Angebote)** | `/auftrag/angebot` | Static | `/auftrag/angebot` |
|
||||
| **New Quote** | `/auftrag/neuesangebot` | Static draft form (Neues Angebot) | `/auftrag/neuesangebot` |
|
||||
| **Quote Detail** | `/auftrag/:quoteId` or `/auftrag/angebot/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
|
||||
| **Shipments (Ausliefern)** | `/auftrag/ausliefern` | Static (shipment manifests & parcels) | `/auftrag/ausliefern` |
|
||||
| **Payments (Zahlungen)** | `/auftrag/zahlungen` | Static (order payments ledger) | `/auftrag/zahlungen` |
|
||||
| **Carriers (Versandarten)** | `/auftrag/versandarten` | Static (Versanddienstleister & Versandarten) | `/auftrag/versandarten` |
|
||||
|
||||
| **Customer List** | `/kunde` | Static | `/kunde` |
|
||||
| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` |
|
||||
| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` |
|
||||
| **Customer Groups** | `/kunde/gruppen` | Static (pricing & customer tiers) | `/kunde/gruppen` |
|
||||
| **Article History** | `/infos` or `/infos/articlehistory` | Static | `/infos/articlehistory` |
|
||||
| **Global History** | `/infos/globalhistory` | Static audit log | `/infos/globalhistory` |
|
||||
| **Statistics** | `/infos/statistics` | Static KPIs & charts | `/infos/statistics` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Parameter Details & Rules
|
||||
|
||||
### Articles (`/artikel`)
|
||||
- **SKU detail**: Always use `encodeURIComponent(sku)` if the SKU contains slashes, spaces, or special characters.
|
||||
- Path: `/artikel/${encodeURIComponent(sku)}`
|
||||
- Example: SKU `A/123` becomes `/artikel/A%2F123`
|
||||
- **Search Query**:
|
||||
- `target`: Search text (min 3 chars to activate search index).
|
||||
- `filter`: Category or facet filter.
|
||||
- `offset`: Pagination offset (integer).
|
||||
- Example: `/artikel?target=schrauben&offset=20`
|
||||
|
||||
### Categories (`/kategorien`)
|
||||
- The route uses hierarchical URL slugs, **not** numeric category IDs.
|
||||
- Format: `/kategorien/{parentSlug}/{childSlug}`
|
||||
- Note: Legacy `/artikel/kategorien/*` is automatically redirected to `/kategorien/*`.
|
||||
|
||||
### Inbound Deliveries (`/lieferung`)
|
||||
- `:deliveryId`: ID of the delivery document (manifest).
|
||||
- To start a new incoming goods intake or receipt reconciliation, route to `/lieferung/neu`.
|
||||
|
||||
### Quotes & Orders (`/auftrag`)
|
||||
- Distinction:
|
||||
- Orders (Auftrag / Bestellungen) live at `/auftrag` and `/auftrag/bestellung/:orderId`.
|
||||
- Quotes (Angebote) live at `/auftrag/angebot` and `/auftrag/:quoteId` (or `/auftrag/angebot/:quoteId`).
|
||||
- Shipments (Ausliefern) live at `/auftrag/ausliefern` (connected to shipment manifests and packages).
|
||||
- Payments (Zahlungen) live at `/auftrag/zahlungen` (payment bookings and payment balances).
|
||||
- Both IDs (`quoteId`, `orderId`) are document IDs.
|
||||
|
||||
|
||||
### Customers (`/kunde`)
|
||||
- `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`).
|
||||
- Opens contact addresses, communications, and customer data tiles.
|
||||
1
.agents/skills/project-style
Symbolic link
1
.agents/skills/project-style
Symbolic link
@@ -0,0 +1 @@
|
||||
coding-style
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,4 +4,7 @@ dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
certs/
|
||||
.dashfront-token
|
||||
.dashfront-token
|
||||
# Visual testing rig: generated screenshots (reference is committed, current/diff are not)
|
||||
test/screenshots/current/
|
||||
test/screenshots/diff/
|
||||
1
.vscode/tmstate.json
vendored
Normal file
1
.vscode/tmstate.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"groups":{},"history":{},"addons":{},"selectedGroup":null,"previousSelectedGroup":null,"quickSlots":{}}
|
||||
577
AGENTS.md
Normal file
577
AGENTS.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# AGENTS.md — Dashfront Developer & Agent Guide
|
||||
|
||||
> **Audience**: AI Coding Agents & Pair-Programmers working on `dashfront`.
|
||||
> **Workspace**: `/home/seb/src/dashfront`
|
||||
> **Last Updated**: 2026-09-21
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview & Ecosystem Map](#1-system-overview--ecosystem-map)
|
||||
2. [Technology Stack & Key Libraries](#2-technology-stack--key-libraries)
|
||||
3. [Directory Layout](#3-directory-layout)
|
||||
4. [State Management Conventions](#4-state-management-conventions)
|
||||
5. [Backend Communication & Authentication Rules](#5-backend-communication--authentication-rules)
|
||||
6. [Route & Navigation Architecture](#6-route--navigation-architecture)
|
||||
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
|
||||
8. [Critical Agent Rules & Common Pitfalls](#8-critical-agent-rules--common-pitfalls)
|
||||
9. [Deep Dive: The Automats (`react-automat`)](#9-deep-dive-the-automats-react-automat)
|
||||
10. [Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)
|
||||
11. [`dashgui` Component Library Integration](#11-dashgui-component-library-integration)
|
||||
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
|
||||
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
|
||||
14. [Real-time SSE Signals & Invalidation](#14-real-time-sse-signals--invalidation)
|
||||
15. [Developer Tooling & Debugging](#15-developer-tooling--debugging)
|
||||
16. [Agent Cheatsheet & Code Recipes](#16-agent-cheatsheet--code-recipes)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview & Ecosystem Map
|
||||
|
||||
`dashfront` is the React web client and Progressive Web App (PWA) for the Dash ERP/POS system. It is part of a multi-repository workspace under `/home/seb/src/`:
|
||||
|
||||
```
|
||||
/home/seb/src/
|
||||
├── dashfront/ # THIS REPO: Vite + React 19 web/PWA client (port 4209 HTTP, 4308 HTTPS)
|
||||
├── dashback/ # Backend Express REST/SSE API server (port 4208)
|
||||
├── dashdbschema/ # LibDashStore (MSSQL data layer, migrations, schema graph, API docs)
|
||||
├── dashgui/ # Reusable custom UI component library (file:../dashgui)
|
||||
└── automat/ # react-automat state management library (file:../automat or npm)
|
||||
```
|
||||
|
||||
### End-to-End Data & Execution Flow
|
||||
|
||||
```yaml
|
||||
# Machine-readable execution flow:
|
||||
flow:
|
||||
- step: 1_client
|
||||
component: dashfront
|
||||
runtime: React 19 (PureComponent & Function Components)
|
||||
state_layers:
|
||||
automats: [artikelAutomat, articleSearchAutomat, articleIndexAutomat, deliveryEditorAutomat, authAutomat, sseAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat, deliveryAutomat, quoteAutomat, orderAutomat, entityAutomat, customergroupAutomat, numberAutomat]
|
||||
transport: src/api (http(), named namespaces)
|
||||
egress:
|
||||
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
|
||||
proxy: Vite dev server (/api -> http://localhost:4208)
|
||||
target: dashback
|
||||
|
||||
- step: 2_api_gateway
|
||||
component: dashback
|
||||
runtime: Express 5 API server (src/api/routes/*)
|
||||
security:
|
||||
auth_middleware: Cookie auth middleware (validates JWT in HTTP-only "token" cookie)
|
||||
context_injection:
|
||||
user_auth_id: req.user.googleAuthId
|
||||
tenant_company_id: req.selectedCompanyId
|
||||
events: Dispatches real-time SSE mutation events to subscribed clients via /api/events
|
||||
egress:
|
||||
interface: In-process method calls (store.<namespace>.<method>())
|
||||
target: dashdbschema
|
||||
|
||||
- step: 3_data_layer
|
||||
component: dashdbschema
|
||||
runtime: LibDashStore (src/store/*)
|
||||
storage: MSSQL connection pool, migrations, and SQL transactions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack & Key Libraries
|
||||
|
||||
| Technology | Version / Spec | Notes |
|
||||
|---|---|---|
|
||||
| **Build & Dev** | Vite 8 (`type: module`) | Dev server on `4209`, HTTPS on `4308`, proxies `/api` → `http://localhost:4208` |
|
||||
| **Framework** | React 19 (`react`, `react-dom`) | Uses `PureComponent` (with Automats & dashgui) and modern function components |
|
||||
| **Routing** | `react-router-dom` v7 | Configured in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) with HMR router preservation |
|
||||
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#85b7ad` in `src/colors.js` |
|
||||
| **Shared GUI** | `dashgui` (`file:../dashgui`) | `PanelTile`, `RecordList`, `Tile`, `SingleLineTextEdit`, `ElevatedPill` |
|
||||
| **State Management** | `react-automat` | Single state layer for all domain & configuration data (articles, categories, company, brands, units, POS terminals, session) |
|
||||
| **Notifications** | `notistack` | `useSnackbar()` / `<SnackbarProvider>` in [src/main.jsx](file:///home/seb/src/dashfront/src/main.jsx) |
|
||||
| **Media & Images** | `@jsquash/avif`, `@jsquash/resize`, `thumbhash` | Client-side AVIF encoding and instant thumbnail hash generation |
|
||||
| **Documents** | `pdfconvert-wasm` | In-browser PDF processing for supplier delivery invoices & receipts |
|
||||
| **Drag & Drop** | `@shopify/draggable`, `sortablejs` | Category re-ordering, article assignments, nested tree nodes |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Backend communication layer
|
||||
│ └── index.js # Canonical API module (article, category, delivery, entity, …)
|
||||
├── automats/ # react-automat state modules (fine-grained subscriptions)
|
||||
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
|
||||
│ ├── authAutomat.js # Authentication state, session bootstrap, company selection
|
||||
│ ├── companyAutomat.js # Active company config (tax rates, root category, legal details)
|
||||
│ ├── categoryAutomat.js # Hierarchical category tree, breadcrumbs, and category articles
|
||||
│ ├── brandAutomat.js # Catalog brands / manufacturer list
|
||||
│ ├── unitAutomat.js # Units of measure (Stück, kg, Liter, ...)
|
||||
│ ├── posterminalAutomat.js # POS terminal config and enabled categories
|
||||
│ ├── deliveryEditorAutomat.js # Multi-line delivery receipt / invoice editor & draft state
|
||||
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
|
||||
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
|
||||
│ ├── userSettingsAutomat.js # User preferences (viewer positions, layout settings)
|
||||
│ └── index.js # Barrel exports for all automats and helper selectors
|
||||
├── components/ # Shared UI components
|
||||
│ ├── AppShell.jsx # Main application frame (top bar, sidebar, nav buttons)
|
||||
│ ├── SectionLayout.jsx # Secondary navigation header for sections (/artikel, /lieferung...)
|
||||
│ ├── AuthBootstrap.jsx # Session bootstrapper, Google One Tap, company setup gate
|
||||
│ ├── LoginPage.jsx # Google One Tap / credentials login interface
|
||||
│ ├── UserMenu.jsx # User avatar, company switcher dropdown, logout action
|
||||
│ ├── BackendError.jsx # Graceful error banner for backend communication failures
|
||||
│ ├── article/ # Article tiles, editors, dialogs, photos, price tiers
|
||||
│ ├── category/ # Category navigation, dialogs, and picker components
|
||||
│ ├── manifest/ # Manifest / delivery line inspection widgets
|
||||
│ ├── settings/ # Terminal, unit, and brand settings dialogs
|
||||
│ └── tiles/ # Tile components extending dashgui base Tile
|
||||
├── devtools/ # Dev-only debug tools
|
||||
│ └── AutomatInspector.jsx # Docked overlay inspecting all registered window.__AUTOMATS__
|
||||
├── pages/ # Top-level view routes
|
||||
│ ├── artikel/ # Article index, category browser, warehouse, SKU detail editor
|
||||
│ ├── lieferung/ # Delivery list, new delivery editor, supplier manager, edit page
|
||||
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
|
||||
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
|
||||
│ └── infos/ # System and company information page
|
||||
├── theme/ # MUI theme definitions (light/dark palette, typography, components)
|
||||
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
|
||||
├── appRouter.jsx # react-router-dom route definitions
|
||||
├── nav.js # Navigation items (NAV_ITEMS, SUB_NAV, badges, active path matchers)
|
||||
├── colors.js # Shared color constants (MAIN_BACKGROUND)
|
||||
├── main.jsx # React root mount, providers, dev SW unregistration
|
||||
└── App.jsx # Main router provider wrapper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. State Management Conventions
|
||||
|
||||
In `dashfront`, two state management solutions coexist harmoniously:
|
||||
|
||||
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
|
||||
- **Skill references**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md) and [.agents/skills/coding-style/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/coding-style/SKILL.md) (names [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) as the reference automat).
|
||||
- Lives outside the React lifecycle in module scope (`src/automats/*.js`).
|
||||
- Designed for React `PureComponent` to bypass unnecessary React reconciliations.
|
||||
- **Pattern**:
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
|
||||
|
||||
class ArticleView extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 1. Synchronous read in constructor — zero blank flash!
|
||||
this.state = {
|
||||
article: getArticle(props.sku),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// 2. Subscribe with fine-grained selector
|
||||
this.unsub = artikelAutomat.subscribe(this, (state) => ({
|
||||
article: createArticleSelector(this.props.sku)(state),
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// 3. Always unsubscribe
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article } = this.state;
|
||||
if (!article) return <div>Lädt...</div>;
|
||||
return <div>{article.name}</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Configuration Automats (`src/automats/`)
|
||||
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
|
||||
- Company-scoped **lists** (article index, deliveries, quotes, orders, entities, customer groups, number sequences) live in list automats built by `createListAutomat`.
|
||||
- Reads use the same fine-grained `subscribe`/selector pattern as the domain automats.
|
||||
- Cross-automat coordination is done by subscribing to `authAutomat`: when it reaches `authed` with a `selectedCompanyId`, these automats load themselves and reset on `anon` (see [§10](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)).
|
||||
|
||||
### C. No React Context for Data (`src/api` has no context layer)
|
||||
- There is **no** store/context provider in this app. Components import the automat or `src/api` namespace they need directly. There is deliberately no `static contextType` for data access.
|
||||
- Call the API through the namespace functions (`article.getBySku(...)`, `delivery.list()`), and keep shared/cached state in an automat.
|
||||
- Barrel exports live in [src/automats/index.js](file:///home/seb/src/dashfront/src/automats/index.js); `src/main.jsx` imports it for its side effects so every store is registered before the session bootstraps.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend Communication & Authentication Rules
|
||||
|
||||
### Authentication & Cookies
|
||||
1. **HTTP-only cookie**: The session token is stored in an HTTP-only cookie (`token`).
|
||||
2. **`credentials: 'include'`**: Every `fetch` request MUST set `credentials: 'include'`. The `http()` helper in [src/api/index.js](file:///home/seb/src/dashfront/src/api/index.js) does this automatically.
|
||||
3. **Never pass `googleAuthId`**: The frontend NEVER passes `googleAuthId` in request bodies or query parameters. The backend middleware extracts the authenticated user directly from the cookie session!
|
||||
4. **Session Company**: Multi-tenancy is scoped to the user's selected company. When a user switches companies (`PUT /api/auth/selected-company`), the backend updates the cookie session. All subsequent calls operate on that company.
|
||||
|
||||
### API Entry Point
|
||||
Import API namespaces from `src/api`:
|
||||
```js
|
||||
import { article, category, delivery, supplier, entity, company } from '../api';
|
||||
|
||||
// Examples:
|
||||
const { article: data } = await article.getBySku('SKU-1001');
|
||||
const { deliveries } = await delivery.list();
|
||||
const { suppliers } = await supplier.list();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Route & Navigation Architecture
|
||||
|
||||
- Main navigation sections are defined in [src/nav.js](file:///home/seb/src/dashfront/src/nav.js):
|
||||
- `/artikel` — Articles overview, SKU editor, Warehouse stock
|
||||
- `/kategorien/*` — Drag-and-drop category tree
|
||||
- `/lieferung` — Inbound deliveries, New delivery editor, Suppliers
|
||||
- `/auftrag` — Quotes (`/auftrag`) and Orders (`/auftrag/bestellung`)
|
||||
- `/kunde` — Customers, Customer groups
|
||||
- `/infos` — System settings & company info
|
||||
- **Router Singleton**:
|
||||
- Defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx).
|
||||
- Preserved across Vite HMR via `import.meta.hot.data.router` to prevent mid-session resets when hot-reloading code.
|
||||
- **Dynamic Badges**:
|
||||
- `DeliveryNavIcon` and `DeliveryNeuIcon` in `src/nav.js` subscribe to `deliveryEditorAutomat` to show live uncommitted draft line badges in real time.
|
||||
|
||||
---
|
||||
|
||||
## 7. Developer Workflows & Commands
|
||||
|
||||
Run commands from the repository root `/home/seb/src/dashfront`:
|
||||
|
||||
```bash
|
||||
# Start Vite development server (proxies /api -> http://localhost:4208)
|
||||
npm run dev
|
||||
|
||||
# Start development server with local HTTPS (uses certs/key.pem and certs/cert.pem)
|
||||
npm run dev:https
|
||||
|
||||
# Run full PWA production build + preview (to test service worker / offline caching)
|
||||
npm run dev:pwa
|
||||
|
||||
# Generate local self-signed TLS certificates
|
||||
npm run cert
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
|
||||
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
|
||||
npm run automat-graph
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When testing camera-based barcode scanning or Progressive Web App features on mobile devices or local network IP addresses, start the server using `npm run dev:https`. Web browsers require a secure context (HTTPS) to grant access to media devices and service workers.
|
||||
|
||||
---
|
||||
|
||||
## 8. Critical Agent Rules & Common Pitfalls
|
||||
|
||||
1. **Do not create dummy/mock backends**: `dashback` runs locally at `http://localhost:4208`. All `/api/*` routes are handled by it.
|
||||
2. **Keep `dashgui` links working**: `dashgui` is imported via `file:../dashgui`. Vite has deduplication configured (`dedupe: ['react', 'react-dom', ...]` in `vite.config.js`) to avoid dual-instance React conflicts.
|
||||
3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_parent: 1` or `childCount > 0`). The backend returns HTTP 400.
|
||||
4. **Prices and Multipliers**:
|
||||
- Article prices are tiered by `minmultiplier` and `customergroup_id`.
|
||||
- Prices sent to the backend are net prices (`net`).
|
||||
5. **Binary / Image Handling**:
|
||||
- Image uploads convert source images into AVIF variants client-side.
|
||||
- Frontend components use `thumbhash` for instant blur placeholders while loading full images via `/api/image/{fullHash}` or `/api/thumb/{fullHash}`.
|
||||
6. **No Hooks in PureComponents**: Never call React hooks (`useState`, `useEffect`) inside `PureComponent` classes. Automats provide direct reactive subscription without hooks.
|
||||
7. **Always Unsubscribe**: Every `automat.subscribe(this, ...)` in `componentDidMount()` must have its cleanup callback called in `componentWillUnmount()`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deep Dive: The Automats (`react-automat`)
|
||||
|
||||
All domain Automats are located in [src/automats/](file:///home/seb/src/dashfront/src/automats/). Each automat exposes an observable state container with explicit action methods:
|
||||
|
||||
### 1. `artikelAutomat` ([src/automats/artikelAutomat.js](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js))
|
||||
- **Role**: Cache and reactive distributor for article detail payloads (`GET /api/article/:sku`).
|
||||
- **Inflight Deduplication**: Uses an internal `Map<string, Promise<object>>` so concurrent UI tiles or navigation transitions requesting the same SKU share a single HTTP call.
|
||||
- **Key Actions**:
|
||||
- `loadArticle(sku)`: Fetches if missing, resolves immediately if cached.
|
||||
- `reloadArticle(sku)`: Silently re-fetches to update cache (used on SSE mutation events).
|
||||
- `setArticle(article)`: Updates cache after mutation responses.
|
||||
- `setArticleName(sku, name)`: Optimistic immediate rename.
|
||||
- `clearArticle(sku)` / `clear()`: Invalidates cache.
|
||||
- **Helper Selectors**: `getArticle(sku)`, `hasArticle(sku)`, `createArticleSelector(sku)`, `selectArticle(sku)`.
|
||||
|
||||
### 2. `authAutomat` ([src/automats/authAutomat.js](file:///home/seb/src/dashfront/src/automats/authAutomat.js))
|
||||
- **Role**: Manages user authentication lifecycle and multi-tenant company selection.
|
||||
- **State**: `{ user: AuthUser | null, googleClientId: string | null, status: 'loading' | 'anon' | 'authed' }`.
|
||||
- **Consumed by other automats**: The company, category, brand, unit, and posterminal automats `subscribe` to `authAutomat` and (re)load or reset on its state changes. `authAutomat` also emits lifecycle events via the registered `setAuthEventDispatcher`:
|
||||
- `auth/sessionEstablished`
|
||||
- `auth/companyChanged`
|
||||
- `auth/loggedOut`
|
||||
- **Actions**: `bootstrapSession(session)`, `switchCompany(companyId)`, `logout()`.
|
||||
|
||||
### 3. `deliveryEditorAutomat` ([src/automats/deliveryEditorAutomat.js](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js))
|
||||
- **Role**: State engine for the multi-line inbound delivery receiver and invoice reconciler ([src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx)).
|
||||
- **Features**:
|
||||
- Line items editor (`lines`, `createEmptyLineitem`).
|
||||
- Supplier article auto-matching via `buildSupplierArticlesMap(articles)` indexing by supplier SKU, internal SKU, and EANs.
|
||||
- PDF/Receipt attachments parsing with OCR integration.
|
||||
- Reactive draft indicators (provides draft counts for badges in `src/nav.js`).
|
||||
|
||||
### 4. `supplierAutomat` ([src/automats/supplierAutomat.js](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js))
|
||||
- **Role**: Caches supplier directory list and maps articles to their corresponding supplier catalogs (`GET /api/supplier`).
|
||||
|
||||
### 5. `navigationAutomat` ([src/automats/navigationAutomat.js](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js))
|
||||
- **Role**: Remembers last active subpaths and last visited article SKU. When a user navigates between the main sidebar sections (e.g. from `/lieferung` to `/artikel` and back), it restores the exact active SKU or delivery document instead of resetting to the root list.
|
||||
|
||||
### 6. `userSettingsAutomat` ([src/automats/userSettingsAutomat.js](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js))
|
||||
- **Role**: Stores client-side user layout preferences such as viewer panel coordinates and drawer positions.
|
||||
|
||||
### 7. `sseAutomat` ([src/automats/sseAutomat.js](file:///home/seb/src/dashfront/src/automats/sseAutomat.js))
|
||||
- **Role**: Owns the **single** `EventSource` for `/api/events` and fans signals out into the domain automats. Reconnects whenever the selected company changes; disconnects on logout.
|
||||
- **Built-in handlers**: `category` → `categoryAutomat.loadTree()` + `invalidateAllCategoryViews()`; `articles` → `articleIndexAutomat.reload()` + category-view invalidation; `warehouse` → `warehouseAutomat.reload()`; `attributename` / `propertyname` → `attributeAutomat.load()` / `propertyAutomat.load()`; `supplier` → `supplierAutomat.reload()`.
|
||||
- **Extension API**:
|
||||
- `sseAutomat.actions.on(event, handler)` — register a handler for a named backend event. Returns an unsubscribe callback. Handlers registered while disconnected attach on the next connect.
|
||||
- `sseAutomat.actions.watchArticle(sku, handler)` — subscribe to `article-<sku>` for one SKU. Exactly one listener is bound per SKU while at least one caller is subscribed; the listener resolves its handler set at call time, so an unwatched SKU is a no-op.
|
||||
- `invalidateArticle(sku)` — reloads the article plus its keyed history/supplier views. Background refreshes stay silent.
|
||||
|
||||
### 8. Company-Scoped List Automats
|
||||
Built with the shared [src/automats/createListAutomat.js](file:///home/seb/src/dashfront/src/automats/createListAutomat.js) factory, which follows the brand/unit/warehouse pattern (`status: idle | loading | ready | failed`):
|
||||
|
||||
| Automat | Backs |
|
||||
|---|---|
|
||||
| `articleIndexAutomat` | Article search index (`GET /api/articleIndex`), mapped to the recordset shape; also patched optimistically on rename |
|
||||
| `deliveryAutomat` | Delivery list |
|
||||
| `quoteAutomat` / `orderAutomat` | Quote / order lists |
|
||||
| `entityAutomat` / `customergroupAutomat` | Customer list and customer groups |
|
||||
| `numberAutomat` | Document number sequences |
|
||||
|
||||
Each exposes `load()` (deduplicated), `reload()` (silent), `replace()`, `ensureLoaded()`, and `reset()`. They subscribe to `authAutomat`, so they load on `authed` with a `selectedCompanyId` and reset to `idle` on `anon`.
|
||||
|
||||
### 9. `articleSearchAutomat` ([src/automats/articleSearchAutomat.js](file:///home/seb/src/dashfront/src/automats/articleSearchAutomat.js))
|
||||
- **Role**: Client-side search over the cached article index for the `/artikel` overview. `search(target)` returns `{ categoryFacets, totalCount, recordset, error }`; the page subscribes to `result` instead of receiving a callback.
|
||||
|
||||
### 10. `articleDetailAutomat` ([src/automats/articleDetailAutomat.js](file:///home/seb/src/dashfront/src/automats/articleDetailAutomat.js))
|
||||
- **Role**: Two `KeyedAutomat` stores keyed by SKU — `articleHistoryAutomat` (`GET /api/article/{sku}/history`, paginated client-side) and `articleSupplierAutomat` (`GET /api/article/{sku}/supplier`). Both reload automatically when `sseAutomat` reports an `article-<sku>` signal.
|
||||
|
||||
---
|
||||
|
||||
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
|
||||
|
||||
The remaining static/relational/configuration state is **also** managed by `react-automat`. These automats live alongside the domain automats in [src/automats/](file:///home/seb/src/dashfront/src/automats/) and follow the same subscribe/selector pattern:
|
||||
|
||||
1. **`companyAutomat`** ([src/automats/companyAutomat.js](file:///home/seb/src/dashfront/src/automats/companyAutomat.js)): Active company configuration, tax rates (standard, reduced), root category ID, company legal details. Selectors: `selectCompanyTaxrate()`, `selectCompanyReducedTaxrate()`, `selectCompanyRootCategoryId()`, `selectCompanyName()`, …
|
||||
2. **`categoryAutomat`** ([src/automats/categoryAutomat.js](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)): Hierarchical category tree (id → node map), breadcrumbs, category navigation, article lists per category, and category/article mutations (`createCategory`, `createArticle`, `deleteCategory`, `moveCategory`).
|
||||
3. **`brandAutomat`** ([src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)): Catalog brands / manufacturer list (`load`, `upsert`).
|
||||
4. **`posterminalAutomat`** ([src/automats/posterminalAutomat.js](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js)): Registered point-of-sale hardware terminals and their enabled categories.
|
||||
5. **`unitAutomat`** ([src/automats/unitAutomat.js](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)): Allowed units of measure (Stück, kg, Liter, etc.).
|
||||
|
||||
### Automatic Bootstrap via `authAutomat` Subscriptions
|
||||
Each of these automats subscribes directly to `authAutomat` at module load and coordinates its own loading:
|
||||
```js
|
||||
// e.g. src/automats/companyAutomat.js
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void companyAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
companyAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
When the auth state becomes `authed` with a `selectedCompanyId` (on session bootstrap, login, or company switch), the company, brand, unit, and posterminal automats load automatically; on logout/anon they reset. `categoryAutomat.loadTree()` then reads the company's `rootCategoryId` from `companyAutomat`.
|
||||
|
||||
### Automatic Debugging
|
||||
All automats are observable in the [AutomatInspector](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx), which lists every automat registered in `window.__AUTOMATS__` with live state, subscriber counts, and actions.
|
||||
|
||||
---
|
||||
|
||||
## 11. `dashgui` Component Library Integration
|
||||
|
||||
`dashgui` is a shared, high-performance UI library linked from `file:../dashgui`:
|
||||
|
||||
### Core Components
|
||||
- **`PanelTile`**: Structured card container featuring standard header styling, action slots, collapsible state, and clean borders.
|
||||
- **`RecordList`**: High-performance list component optimized for large dataset rendering with custom row click, hover, and selection handlers.
|
||||
- **`Tile`**: Base container element for building responsive dashboard grids.
|
||||
- **`SingleLineTextEdit`**: Inline editable text field with debounced autosave, blur commit, and escape rollback.
|
||||
- **`ElevatedPill`**: Compact visual badges for status, tags, and category chips.
|
||||
|
||||
### React 19 & Deduplication Warning
|
||||
Because `dashgui` is symlinked from `../dashgui`, it has its own `node_modules`. To prevent duplicate React, Emotion, or MUI instances from causing runtime context errors (`Cannot read properties of null (reading 'useContext')`), [vite.config.js](file:///home/seb/src/dashfront/vite.config.js) enforces `dedupe`:
|
||||
```js
|
||||
resolve: {
|
||||
dedupe: [
|
||||
'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime',
|
||||
'react-router', 'react-router-dom', '@emotion/react', '@emotion/styled',
|
||||
'@mui/material', '@mui/icons-material'
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Application Views & Route Hierarchy
|
||||
|
||||
The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) and styled with [src/components/AppShell.jsx](file:///home/seb/src/dashfront/src/components/AppShell.jsx):
|
||||
|
||||
```
|
||||
/
|
||||
├── /artikel
|
||||
│ ├── index → ArtikelUebersichtPage (Search & filter grid)
|
||||
│ ├── /kategorien/* → ArtikelKategorienPage (Interactive category tree)
|
||||
│ ├── /lager → ArtikelLagerPage (Warehouse stock overview)
|
||||
│ └── /:sku/:tab? → ArtikelSkuPage (SKU detail: info, photos, prices, suppliers, warehouse)
|
||||
├── /lieferung
|
||||
│ ├── index → LieferungListePage (Inbound delivery list)
|
||||
│ ├── /neu → LieferungNeuPage (Receipt editor & OCR reconciliation)
|
||||
│ ├── /lieferanten → LieferungLieferantenPage (Supplier directory)
|
||||
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
|
||||
├── /auftrag
|
||||
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
|
||||
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
|
||||
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
|
||||
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
|
||||
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
|
||||
│ └── /bestellung/:id → AuftragBestellungEditPage (Edit order)
|
||||
├── /kunde
|
||||
│ ├── index → KundeListePage (Customer directory)
|
||||
│ ├── /neu → KundeNeuPage (Create customer)
|
||||
│ ├── /gruppen → KundeGruppenPage (Customer groups & price tiers)
|
||||
│ └── /:entityId → KundeEditPage (Customer profile & address editor)
|
||||
└── /infos → InfosPage (Company information & POS setup)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Media, Images & Document Pipelines
|
||||
|
||||
### Client-Side AVIF Conversion
|
||||
To minimize backend CPU usage and reduce bandwidth:
|
||||
- When a user selects or drops an article photo, the frontend processes the image in-browser using `@jsquash/avif` and `@jsquash/resize`.
|
||||
- Images are scaled to target variant dimensions and compressed into AVIF format before transmission to `/api/article/:sku/photo`.
|
||||
|
||||
### ThumbHash Placeholders
|
||||
- For instant visual feedback without layout shift, article photos generate a 25-byte binary hash using `thumbhash`.
|
||||
- [src/components/article/ThumbHashImage.jsx](file:///home/seb/src/dashfront/src/components/article/ThumbHashImage.jsx) renders the blur preview instantly while the full image loads asynchronously from `/api/thumb/{hash}` or `/api/image/{hash}`.
|
||||
|
||||
### PDF & Document Ingestion
|
||||
- [src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx) utilizes `pdfconvert-wasm` to process supplier PDF delivery notes and invoices directly inside the browser.
|
||||
- Extracted pages are rendered to canvas, displayed for line-by-line inspection, and passed to OCR recognition to populate delivery line items.
|
||||
|
||||
---
|
||||
|
||||
## 14. Real-time SSE Signals & Invalidation
|
||||
|
||||
The frontend connects to the backend Server-Sent Events stream at `/api/events`:
|
||||
- **Architecture**: When mutations occur on `dashback` (whether triggered by this client, another terminal, or the POS), SSE events are broadcast with payload information (e.g. `{ type: 'article-update', sku: '1001' }`).
|
||||
- **Reactive Cache Refresh**:
|
||||
- `artikelAutomat.actions.reloadArticle(sku)` re-fetches the updated article silently, updating subscribers without flicker.
|
||||
- Category updates trigger `categoryAutomat` tree/article reloads.
|
||||
- Delivery and order updates trigger automatic list reloads.
|
||||
|
||||
---
|
||||
|
||||
## 15. Developer Tooling & Debugging
|
||||
|
||||
### AutomatInspector ([src/devtools/AutomatInspector.jsx](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx))
|
||||
- Docked overlay on the right side of the screen, active only in development mode (`import.meta.env.DEV`).
|
||||
- Auto-discovers all running automats registered in `window.__AUTOMATS__`.
|
||||
- Allows developers and agents to:
|
||||
- Inspect full state JSON trees in real time.
|
||||
- View subscriber counts and registered actions.
|
||||
- Trigger actions and test state changes without code modifications.
|
||||
|
||||
### Vite API Proxy Logging
|
||||
[vite.config.js](file:///home/seb/src/dashfront/vite.config.js) includes automatic proxy logging for all `/api` requests and responses. HTTP method, path, request payloads, response statuses, and timing are formatted directly in the Vite terminal.
|
||||
|
||||
### TLS / HTTPS Development
|
||||
For testing hardware barcode scanners, camera video feeds, and PWA offline modes:
|
||||
```bash
|
||||
npm run cert # Generates local certs in ./certs/
|
||||
npm run dev:https # Runs Vite HTTPS server on port 4308
|
||||
```
|
||||
|
||||
### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`)
|
||||
- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`).
|
||||
- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`.
|
||||
- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI).
|
||||
- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange).
|
||||
- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops.
|
||||
|
||||
---
|
||||
|
||||
## 16. Agent Cheatsheet & Code Recipes
|
||||
|
||||
### Recipe 1: Consuming an Automat in a React PureComponent
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
|
||||
|
||||
export class ArticleTitle extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 1. Read synchronously to avoid blank flash
|
||||
this.state = {
|
||||
article: getArticle(props.sku),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// 2. Subscribe with fine-grained selector
|
||||
this.unsub = artikelAutomat.subscribe(this, (state) => ({
|
||||
article: createArticleSelector(this.props.sku)(state),
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// 3. Handle prop changes cleanly
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
this.setState({ article: getArticle(this.props.sku) });
|
||||
this.unsub?.();
|
||||
this.unsub = artikelAutomat.subscribe(this, (state) => ({
|
||||
article: createArticleSelector(this.props.sku)(state),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// 4. Always cleanup
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article } = this.state;
|
||||
return <h1>{article?.name ?? 'Lädt...'}</h1>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recipe 2: Calling the API and Notifying Errors
|
||||
```jsx
|
||||
import { article } from '../api';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { artikelAutomat } from '../automats';
|
||||
|
||||
async function handleSavePrice(sku, netPrice) {
|
||||
try {
|
||||
const res = await article.updatePrice(sku, { net: netPrice });
|
||||
artikelAutomat.actions.setArticle(res.article);
|
||||
enqueueSnackbar('Preis erfolgreich aktualisiert', { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recipe 3: Calculating Gross Prices with Company Tax Rates
|
||||
```jsx
|
||||
// Prices in the database and API are NET prices.
|
||||
// Gross prices shown to end users must apply company tax rates from companyAutomat
|
||||
// (selectCompanyTaxrate() / selectCompanyReducedTaxrate()):
|
||||
export function calculateGrossPrice(netPrice, taxRatePercent) {
|
||||
const net = Number(netPrice) || 0;
|
||||
const rate = Number(taxRatePercent) || 0;
|
||||
return Math.round(net * (1 + rate / 100) * 100) / 100;
|
||||
}
|
||||
```
|
||||
466
NGINX.md
Normal file
466
NGINX.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# NGINX Configuration Guide for Dashfront & Dashback
|
||||
|
||||
This document provides a production-tested, complete NGINX reverse-proxy and static hosting guide for **Dashfront** (Vite + React 19 PWA) and **Dashback** (Express REST + SSE API).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture & Request Flow](#1-architecture--request-flow)
|
||||
2. [Key Technical Requirements](#2-key-technical-requirements)
|
||||
3. [Production Configuration (`dashfront.conf`)](#3-production-configuration-dashfrontconf)
|
||||
4. [Detailed Breakdown of Critical Directives](#4-detailed-breakdown-of-critical-directives)
|
||||
- [PWA & Cache Invalidation Strategy](#pwa--cache-invalidation-strategy)
|
||||
- [SPA Routing (`try_files`)](#spa-routing-try_files)
|
||||
- [Server-Sent Events (SSE) Buffering & Timeouts](#server-sent-events-sse-buffering--timeouts)
|
||||
- [File Uploads & Request Body Limits](#file-uploads--request-body-limits)
|
||||
- [WebAssembly (WASM) MIME Types](#webassembly-wasm-mime-types)
|
||||
- [Camera & Hardware Permissions](#camera--hardware-permissions)
|
||||
5. [Alternative: Remote Vite Dev Server Proxy](#5-alternative-remote-vite-dev-server-proxy)
|
||||
6. [Deployment & Operations Runbook](#6-deployment--operations-runbook)
|
||||
7. [Troubleshooting Matrix](#7-troubleshooting-matrix)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture & Request Flow
|
||||
|
||||
In production, NGINX acts as both the high-performance static web server for Dashfront assets and the secure reverse proxy for the Dashback Express service.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ CLIENT │
|
||||
│ (Desktop Browser, Tablet, Mobile PWA) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ HTTPS (:443)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ NGINX │
|
||||
│ TLS Termination, Gzip, Caching, Auth │
|
||||
└──────────────┬────────────────┬──────────────┘
|
||||
│ │
|
||||
/assets/*, /index.html │ │ /api/*
|
||||
/sw.js, static files │ │ (REST & SSE)
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Static Build Dir │ │ Dashback │
|
||||
│ `dashfront/dist` │ │ Express API │
|
||||
│ │ │ (localhost:4208) │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
| Path | Handled By | Cache Policy | Notes |
|
||||
|---|---|---|---|
|
||||
| `/index.html` & `/` | NGINX static file | `no-cache, no-store, must-revalidate` | Always fresh so users get new script hashes |
|
||||
| `/sw.js`, `/registerSW.js` | NGINX static file | `no-cache, no-store, must-revalidate` | **Must never cache**; avoids bricking PWA updates |
|
||||
| `/workbox-*.js` | NGINX static file | `no-cache, no-store, must-revalidate` | Workbox runtime loader |
|
||||
| `/manifest.webmanifest` | NGINX static file | `public, max-age=3600` | PWA manifest definition |
|
||||
| `/assets/*` | NGINX static file | `public, max-age=31536000, immutable` | Content-hashed JS, CSS, WASM, and workers |
|
||||
| `/api/events` | Dashback (`:4208`) | `no-cache, no-buffering` | Server-Sent Events (SSE) live push notifications |
|
||||
| `/api/*` | Dashback (`:4208`) | Dynamic / Backend controlled | REST endpoints, auth cookies, image & PDF uploads |
|
||||
| All other paths | NGINX `try_files` | Fallback to `/index.html` | React Router HTML5 History API deep linking |
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Technical Requirements
|
||||
|
||||
1. **PWA Lifecycles**: A Progressive Web App checks for updates by fetching `sw.js`. If NGINX caches `sw.js` or `index.html`, client devices can remain stuck on obsolete bundles indefinitely.
|
||||
2. **SSE Streaming**: Real-time push updates at `/api/events` require NGINX proxy buffering and proxy caching to be turned **OFF**, with a 24-hour read timeout to prevent arbitrary 60-second connection drops.
|
||||
3. **Payload Limits**: Inbound delivery invoices (PDF receipts scanned client-side) and high-resolution article photos (AVIF) require a request body limit of at least `50m`–`100m` (matching Dashback's `BODY_LIMIT`).
|
||||
4. **Cookie Authentication**: Dashback issues HTTP-only `token` session cookies. NGINX must pass the original `Host`, `X-Real-IP`, and `X-Forwarded-Proto https` headers so Express sets and retains cookies correctly.
|
||||
5. **Camera Access (HTTPS)**: Mobile barcode scanning requires a secure origin (HTTPS) and appropriate `Permissions-Policy` response headers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Production Configuration (`dashfront.conf`)
|
||||
|
||||
Place this configuration file at `/etc/nginx/sites-available/dashfront` (or adapt it to your existing reverse proxy structure):
|
||||
|
||||
```nginx
|
||||
# ==============================================================================
|
||||
# Dashfront + Dashback Production NGINX Server Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Map Connection header for WebSocket upgrades (if proxying dev server or WebSockets)
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 1. HTTP -> HTTPS Redirect
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name dash.example.com; # <-- Replace with your domain or server IP
|
||||
|
||||
# Let's Encrypt / ACME challenge directory
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 2. Main HTTPS Server Block
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
# NGINX >= 1.25.1 uses 'http2 on;'. For older NGINX, use: listen 443 ssl http2;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name dash.example.com; # <-- Replace with your domain or server IP
|
||||
|
||||
# --- SSL / TLS Certificates ---
|
||||
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# --- Static File Root ---
|
||||
# Point directly to the dist/ directory created by `npm run build`
|
||||
root /home/seb/src/dashfront/dist;
|
||||
index index.html;
|
||||
|
||||
# --- Request Body & Uploads ---
|
||||
# Supports large delivery PDF uploads and client-encoded AVIF photos
|
||||
client_max_body_size 100m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# --- Compression (Gzip) ---
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/manifest+json
|
||||
application/wasm
|
||||
image/svg+xml;
|
||||
|
||||
# --- Security Headers ---
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# Allow camera access for the built-in barcode scanner
|
||||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
|
||||
|
||||
# ==========================================================================
|
||||
# API & Backend Routing (Dashback on port 4208)
|
||||
# ==========================================================================
|
||||
|
||||
# Dedicated SSE route: Real-time broadcast events
|
||||
location = /api/events {
|
||||
proxy_pass http://127.0.0.1:4208;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Clear Connection header to maintain persistent HTTP/1.1 stream
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Disable buffering and caching completely for immediate push
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
# Prevent premature disconnections; keep SSE open for 24 hours
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# General API endpoints (REST)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4208;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Retain cookies across domain/path
|
||||
proxy_cookie_path / /;
|
||||
|
||||
# Streaming uploads (PDF receipts, AVIF photos)
|
||||
proxy_request_buffering off;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# PWA & Service Worker Rules (Zero Caching)
|
||||
# ==========================================================================
|
||||
|
||||
# Service worker scripts MUST NEVER be cached by HTTP cache
|
||||
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# PWA web manifest and browser icons
|
||||
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Vary "Accept-Encoding";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
|
||||
# ==========================================================================
|
||||
|
||||
# Vite content-hashed assets: JS, CSS, WASM, Web Workers
|
||||
# (Example: /assets/index-CutpK3vd.js, /assets/avif_enc-Co4TcJko.wasm)
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Vary "Accept-Encoding";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Ensure WebAssembly MIME type is correctly set
|
||||
location ~* \.wasm$ {
|
||||
default_type application/wasm;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# HTML5 SPA Entry Point & Navigation Fallback
|
||||
# ==========================================================================
|
||||
|
||||
# index.html must never be cached so new release deployments take effect immediately
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# SPA routing: Serve existing static file or directory; otherwise fallback to /index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Detailed Breakdown of Critical Directives
|
||||
|
||||
### PWA & Cache Invalidation Strategy
|
||||
|
||||
PWA update lifecycles depend on byte-for-byte comparisons of `sw.js` when the user visits the page:
|
||||
1. **`sw.js` and `registerSW.js`**:
|
||||
```nginx
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
```
|
||||
If NGINX serves `sw.js` with a 30-day cache header, browser service worker checks will read from disk cache and never detect new application releases.
|
||||
2. **`index.html`**:
|
||||
Must also have `no-cache, no-store, must-revalidate`. When a new build is run, Vite produces chunks with new hashes (e.g., `index-AbCd12.js`). If `index.html` is cached, users continue loading old references that may no longer exist on disk.
|
||||
3. **`/assets/`**:
|
||||
Because Vite / Rolldown generates content-addressed filenames (hashes change whenever contents change), these files are completely immutable and safe to cache for 1 year (`max-age=31536000, immutable`).
|
||||
|
||||
### SPA Routing (`try_files`)
|
||||
|
||||
Dashfront uses React Router with HTML5 History API pushState:
|
||||
- Direct visits to URLs like `/artikel/10001`, `/kategorien/12`, `/lieferung/neu`, or `/kunde` do not correspond to files on disk.
|
||||
- The directive:
|
||||
```nginx
|
||||
try_files $uri $uri/ /index.html;
|
||||
```
|
||||
instructs NGINX to first check if a matching static file exists (e.g. `/favicon.svg` or `/assets/...`). If not found, it internally rewrites the request to `/index.html` without returning an HTTP 404, allowing React Router to resolve the route in-browser.
|
||||
|
||||
### Server-Sent Events (SSE) Buffering & Timeouts
|
||||
|
||||
The backend provides real-time model invalidation via `GET /api/events`. By default, NGINX buffers HTTP responses. If buffering is enabled:
|
||||
1. Events sent by Dashback stay in NGINX's internal memory buffer until the buffer fills up (often several kilobytes), causing clients to miss instant UI updates.
|
||||
2. NGINX will close idle proxy connections after `proxy_read_timeout` (default 60s).
|
||||
|
||||
The dedicated `/api/events` block fixes both issues:
|
||||
```nginx
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 86400s;
|
||||
```
|
||||
|
||||
### File Uploads & Request Body Limits
|
||||
|
||||
In Dashfront:
|
||||
- Inbound deliveries support multi-page PDF invoices and OCR processing (`pdfconvert-wasm`).
|
||||
- Article management supports client-side AVIF image generation.
|
||||
- The default NGINX limit is `1m` (`client_max_body_size 1m;`), which causes HTTP 413 (Payload Too Large) errors on invoice uploads.
|
||||
- Setting:
|
||||
```nginx
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
```
|
||||
allows uploads up to 100 megabytes and streams the body directly to Express without waiting for NGINX to buffer the entire file on disk.
|
||||
|
||||
### WebAssembly (WASM) MIME Types
|
||||
|
||||
Dashfront uses WebAssembly for:
|
||||
- `@jsquash/avif` (image encoding)
|
||||
- `@jsquash/resize` (squoosh resizing)
|
||||
- `pdfconvert-wasm` (PDF page extraction and rasterization)
|
||||
|
||||
Modern browsers require the HTTP header `Content-Type: application/wasm` for streaming compilation (`WebAssembly.instantiateStreaming`). If missing, NGINX may default to `application/octet-stream`. The WASM block guarantees the correct MIME header:
|
||||
```nginx
|
||||
location ~* \.wasm$ {
|
||||
default_type application/wasm;
|
||||
}
|
||||
```
|
||||
|
||||
### Camera & Hardware Permissions
|
||||
|
||||
The article lookup and delivery receipt features allow scanning barcodes using device cameras. Web browsers enforce two strict rules for camera access:
|
||||
1. **Secure Origin**: The page must be served over HTTPS or `localhost`. Plain HTTP over local IP addresses (e.g., `http://192.168.1.100/`) will automatically disable `navigator.mediaDevices`.
|
||||
2. **Permissions Policy**: The header `Permissions-Policy "camera=(self)"` ensures the browser allows Dashfront to access connected webcams or smartphone cameras.
|
||||
|
||||
---
|
||||
|
||||
## 5. Alternative: Remote Vite Dev Server Proxy
|
||||
|
||||
If you want to access the live **Vite development server** (running on `localhost:4209` with HMR) through NGINX from a smartphone, tablet, or external domain:
|
||||
|
||||
```nginx
|
||||
# Map connection upgrade for Vite HMR WebSockets
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name dev-dash.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/dev-dash.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/dev-dash.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 100m;
|
||||
|
||||
# 1. API Calls -> Dashback (Port 4208)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4208;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# 2. Vite Dev Server & HMR -> Vite (Port 4209)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4209;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Crucial for Vite Hot Module Replacement (HMR) WebSocket
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When proxying to Vite dev server, ensure `server.allowedHosts = true` or add your domain to `allowedHosts` in [vite.config.js](file:///home/seb/src/dashfront/vite.config.js).
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment & Operations Runbook
|
||||
|
||||
### Step 1: Build Dashfront for Production
|
||||
|
||||
On the development machine or build server:
|
||||
|
||||
```bash
|
||||
cd /home/seb/src/dashfront
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles optimized bundles and service worker files into `dashfront/dist`.
|
||||
|
||||
### Step 2: Install and Verify NGINX Configuration
|
||||
|
||||
```bash
|
||||
# 1. Copy config file to sites-available
|
||||
sudo cp deploy/nginx-dashfront.conf /etc/nginx/sites-available/dashfront
|
||||
|
||||
# 2. Enable site by creating a symlink
|
||||
sudo ln -s /etc/nginx/sites-available/dashfront /etc/nginx/sites-enabled/
|
||||
|
||||
# 3. Test configuration syntax
|
||||
sudo nginx -t
|
||||
|
||||
# 4. Reload NGINX without dropping active connections
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Step 3: Obtain TLS Certificates with Certbot
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d dash.example.com
|
||||
```
|
||||
|
||||
### Step 4: Ensure Filesystem Permissions
|
||||
|
||||
NGINX worker processes run as the `www-data` user by default. Ensure the directory path to `dist/` is readable:
|
||||
|
||||
```bash
|
||||
# Allow www-data execution on parent path components
|
||||
chmod 755 /home/seb /home/seb/src /home/seb/src/dashfront /home/seb/src/dashfront/dist
|
||||
```
|
||||
|
||||
Or deploy build artifacts into a standard web directory:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/dashfront
|
||||
sudo rsync -av --delete /home/seb/src/dashfront/dist/ /var/www/dashfront/dist/
|
||||
sudo chown -R www-data:www-data /var/www/dashfront
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting Matrix
|
||||
|
||||
| Symptom | Cause | Solution |
|
||||
|---|---|---|
|
||||
| **HTTP 404 on page reload** on sub-routes (`/artikel`, `/kunde`) | Missing SPA fallback in NGINX | Ensure `try_files $uri $uri/ /index.html;` is present in `location /`. |
|
||||
| **PWA does not update** after deployment | `sw.js` or `index.html` cached in browser | Verify `Cache-Control: no-cache, no-store, must-revalidate` on `sw.js`, `registerSW.js`, and `index.html`. |
|
||||
| **HTTP 413 (Payload Too Large)** when uploading invoice PDF or image | Default NGINX upload limit is 1MB | Add `client_max_body_size 100m;` to the `server` or `location /api/` block. |
|
||||
| **Live SSE events disconnect every 60s** | Default `proxy_read_timeout` timeout | Set `proxy_read_timeout 86400s;` and `proxy_buffering off;` on `/api/events`. |
|
||||
| **Login loop / Cookie not sent** | Secure cookie rejected on plain HTTP, or missing proxy headers | Access via HTTPS; ensure `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` are set. |
|
||||
| **Barcode camera scanner disabled** | Page accessed over insecure HTTP or missing permissions policy | Use HTTPS; check that `Permissions-Policy "camera=(self)"` header is present. |
|
||||
| **WASM compilation error** (`incorrect response MIME type`) | NGINX serving `.wasm` as `application/octet-stream` | Add `default_type application/wasm;` for `\.wasm$` extensions. |
|
||||
| **Vite HMR disconnects immediately** (Dev proxy) | Missing WebSocket upgrade headers | Ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection $connection_upgrade;` are configured. |
|
||||
@@ -24,6 +24,8 @@ Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
|
||||
To test the full PWA precache (production build + preview, no HMR), use
|
||||
`npm run dev:pwa` instead.
|
||||
|
||||
For production deployment with NGINX, see [NGINX.md](file:///home/seb/src/dashfront/NGINX.md).
|
||||
|
||||
Dashback must be running and your Google account must be authorized on the
|
||||
backend.
|
||||
|
||||
@@ -56,10 +58,13 @@ future test cases.
|
||||
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
|
||||
requirement without needing HTTPS on the frontend dev server.
|
||||
|
||||
## Architecture
|
||||
## Architecture & State Management
|
||||
|
||||
- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs).
|
||||
- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships.
|
||||
- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm.
|
||||
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
|
||||
- `src/auth/googleSignIn.js` — Google Identity Services helper.
|
||||
- `src/auth/AuthProvider.jsx` — session state + login/logout.
|
||||
- `src/auth/useAuth.js` — auth context hook.
|
||||
- `src/components/*` — small function components with local state.
|
||||
- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors.
|
||||
|
||||
134
README.redux.md
134
README.redux.md
@@ -1,134 +0,0 @@
|
||||
# Redux in Dashfront
|
||||
|
||||
Übersicht, wie Redux Toolkit bisher im Frontend eingesetzt wird.
|
||||
|
||||
## Stack
|
||||
|
||||
- **@reduxjs/toolkit** — Store, Slices, Async Thunks, Listener Middleware
|
||||
- **react-redux** — `Provider` in `src/main.jsx`, Anbindung der UI über `connect()` (kein `useSelector` / `useDispatch`)
|
||||
|
||||
Einstieg:
|
||||
|
||||
```
|
||||
src/main.jsx → <Provider store={store}>
|
||||
src/store/index.js → configureStore + Middleware
|
||||
src/store/*Slice.js → State, Thunks, Selectors
|
||||
```
|
||||
|
||||
## Store-Form
|
||||
|
||||
Drei Slices unter `src/store/`:
|
||||
|
||||
| Key | Datei | Verantwortung |
|
||||
|------------|---------------------|----------------------------------------------------|
|
||||
| `auth` | `authSlice.js` | Session, Login/Logout, Firmenwahl / -anlage |
|
||||
| `company` | `companySlice.js` | Aktuelles Unternehmen laden / aktualisieren |
|
||||
| `category` | `categorySlice.js` | Kategoriebaum, Navigation, Artikel je Kategorie |
|
||||
|
||||
```js
|
||||
// src/store/index.js
|
||||
configureStore({
|
||||
reducer: { auth, company, category },
|
||||
middleware: defaults + listenerMiddleware + (DEV: reduxDevLogger),
|
||||
});
|
||||
```
|
||||
|
||||
## Typische Muster
|
||||
|
||||
### Async Thunks als API-Grenze
|
||||
|
||||
Seiten- und Dialog-Logik dispatcht Thunks; die Thunks rufen `src/api` auf und schreiben Ergebnisse in den Slice. Komponenten sprechen die API nicht direkt für diese Domänen an.
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `auth/bootstrap`, `auth/login`, `auth/selectCompany`, `auth/createCompany`, `auth/logout`
|
||||
- `company/loadCurrent`, `company/update`
|
||||
- `category/loadTree`, `category/loadArticles`, `category/create`, `category/createArticle`, `category/delete`
|
||||
|
||||
Fehler in Category-Thunks laufen über `catchBackendError` / `rejectWithValue` und landen als `BackendError` in `state.category.error`. Tree- und Artikel-Responses werden mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validiert.
|
||||
|
||||
### Selectors
|
||||
|
||||
Jeder Slice exportiert `select…`-Funktionen. UI-Komponenten mappen sie in `mapStateToProps`:
|
||||
|
||||
```js
|
||||
const mapStateToProps = (state) => ({
|
||||
status: selectAuthStatus(state),
|
||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
||||
});
|
||||
export default connect(mapStateToProps)(App);
|
||||
```
|
||||
|
||||
Abgeleitete Category-Selectors (Breadcrumbs, Kinder, „kann zurück“) lesen die Map `categories` und `currentCategoryId` — keine zweite Kopie des Baums in der UI.
|
||||
|
||||
### `connect()` statt Hooks
|
||||
|
||||
Die Redux-gebundenen Komponenten sind Class- oder Function-Komponenten mit:
|
||||
|
||||
```js
|
||||
connect(mapStateToProps, mapDispatchToProps)(Component)
|
||||
```
|
||||
|
||||
Dispatch-Props wrappen Thunks, oft mit `.unwrap()` in der Komponente für Erfolg/Fehler (Snackbar).
|
||||
|
||||
### Listener: Auth → Company
|
||||
|
||||
Nach erfolgreichem Bootstrap, Login, Firmenwahl oder Firmenanlage dispatcht ein Listener automatisch `loadCurrentCompany`, wenn `auth.user.selectedCompanyId` gesetzt ist (`companyLoadListener` in `companySlice.js`).
|
||||
|
||||
### Cross-Slice-Reset
|
||||
|
||||
- `logout.fulfilled` leert `company` und setzt `category` auf `initialState`
|
||||
- `loadCurrentCompany.fulfilled` mit neuer `companyId` setzt den Category-State zurück (Firmenwechsel)
|
||||
|
||||
## Datenfluss (Kurz)
|
||||
|
||||
```
|
||||
App-Start
|
||||
└─ AuthBootstrap → bootstrapSession
|
||||
└─ (listener) loadCurrentCompany wenn selectedCompanyId
|
||||
└─ Category-UI → loadCategoryTree (braucht company.rootCategoryId)
|
||||
|
||||
Login / Firmenwahl / -anlage
|
||||
└─ auth-Thunks → user.selectedCompanyId
|
||||
└─ (listener) loadCurrentCompany → ggf. Category-Reset
|
||||
|
||||
Kategorien-Seite
|
||||
└─ navigateToCategory / navigateBack (sync)
|
||||
└─ loadCategoryArticles für Unterkategorien
|
||||
└─ createCategory / createArticle / deleteCategory (optimistisch im Map-/Listen-State)
|
||||
```
|
||||
|
||||
Status-Felder:
|
||||
|
||||
| Slice | Feld(er) | Werte (typisch) |
|
||||
|----------|----------------------------------|------------------------|
|
||||
| auth | `status` | `loading` / `anon` / `authed` |
|
||||
| company | `status` | `idle` / `loading` / `succeeded` / `failed` |
|
||||
| category | `treeStatus`, `articlesStatus` | `idle` / `loading` / `succeeded` / `failed` |
|
||||
|
||||
## Auth-Slice
|
||||
|
||||
- **State:** `user`, `googleClientId`, `status`
|
||||
- **Setup-Gate:** `selectNeedsCompanySetup` → eingeloggt, aber keine `selectedCompanyId` → `CompanySetupDialog`
|
||||
- `mergeUser` behält beim Partial-Update ggf. die bisherige `selectedCompanyId`
|
||||
|
||||
## Company-Slice
|
||||
|
||||
- **State:** `current` (normalisiert über `normalizeCompany`), `status`, `error`
|
||||
- Thunks lesen `auth.user.selectedCompanyId` aus dem Store (`getState`)
|
||||
- Selectors liefern u. a. Name, `iso639`, Steuersätze, `rootCategoryId` für Category/UI
|
||||
|
||||
## Category-Slice
|
||||
|
||||
- **Baum:** `categories` als Id→Node-Map, plus `rootCategoryId`, `rootArticles`, `languageId`
|
||||
- **Navigation:** `currentCategoryId`; Root zeigt `rootArticles`, Unterkategorien laden per `loadCategoryArticles`
|
||||
- **Sync-Actions:** `navigateToCategory`, `navigateBack`, `resetCategoryState`
|
||||
- Hilfsfunktionen (`normalizeCategoryNode`, `parseCategoryTreeResponse`, Breadcrumbs, Slugs) leben im Slice und werden u. a. von `categoryRoute.js` für URL-Pfade wiederverwendet
|
||||
|
||||
## Dev-Logging
|
||||
|
||||
In Development loggt `reduxDevLogger` Actions unter `auth/`, `company/`, `category/` inkl. Payload und kurzem Auth-/Company-Snapshot nach dem Reduce.
|
||||
|
||||
## Was (noch) nicht in Redux liegt
|
||||
|
||||
Artikel-Detail, Fotos und ähnliche lokale UI-Zustände werden in Komponenten / lokalen Props gehalten, nicht als eigener Slice. Neue Domänen folgen dem bestehenden Muster: API in `src/api`, Thunk + Validierung im Slice, Selectors + `connect` in der UI.
|
||||
1066
artikeltabelle.csv
1066
artikeltabelle.csv
File diff suppressed because it is too large
Load Diff
121
automat-graph.dot
Normal file
121
automat-graph.dot
Normal file
File diff suppressed because one or more lines are too long
1676
automat-graph.svg
Normal file
1676
automat-graph.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 199 KiB |
179
deploy/nginx-dashfront.conf
Normal file
179
deploy/nginx-dashfront.conf
Normal file
@@ -0,0 +1,179 @@
|
||||
# ==============================================================================
|
||||
# Dashfront + Dashback Production NGINX Server Configuration
|
||||
# ==============================================================================
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 1. HTTP -> HTTPS Redirect
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name dashdev.quixpos.com;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 2. Main HTTPS Server Block
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name dashdev.quixpos.com;
|
||||
|
||||
# --- SSL / TLS Certificates ---
|
||||
ssl_certificate /etc/letsencrypt/live/dashdev.quixpos.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/dashdev.quixpos.com/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# --- Header Buffer Limits (prevents HTTP/2 RST_STREAM / ERR_HTTP2_PROTOCOL_ERROR) ---
|
||||
large_client_header_buffers 4 128k;
|
||||
|
||||
# --- Static File Root ---
|
||||
root /home/seb/src/dashfront/dist;
|
||||
index index.html;
|
||||
|
||||
# --- Request Body & Uploads (PDF receipts, AVIF photos) ---
|
||||
client_max_body_size 100m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# --- Compression (Gzip) ---
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/manifest+json
|
||||
application/wasm
|
||||
image/svg+xml;
|
||||
|
||||
# --- Security Headers ---
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
|
||||
|
||||
# ==========================================================================
|
||||
# API & Backend Routing (Dashback on port 4208)
|
||||
# ==========================================================================
|
||||
|
||||
# Dedicated SSE route: Real-time broadcast events
|
||||
location = /api/events {
|
||||
proxy_pass http://127.0.0.1:4208;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Clear Connection & Accept-Encoding to prevent upstream buffering
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Disable buffering and compression completely for immediate push
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding off;
|
||||
gzip off;
|
||||
|
||||
# Disable HTTP/2 request limits for persistent SSE connection
|
||||
# (Prevents NGINX from resetting long-lived streams)
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# General API endpoints (REST)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4208;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_cookie_path / /;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# PWA & Service Worker Rules (Zero Caching)
|
||||
# ==========================================================================
|
||||
|
||||
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Vary "Accept-Encoding";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
|
||||
# ==========================================================================
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Vary "Accept-Encoding";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.wasm$ {
|
||||
default_type application/wasm;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# HTML5 SPA Entry Point & Navigation Fallback
|
||||
# ==========================================================================
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
91
docs/README.md
Normal file
91
docs/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Dashfront Automats Documentation
|
||||
|
||||
This directory contains reference documentation for all state automats in `dashfront` (`src/automats/`), powered by [`react-automat`](https://github.com/sebgreen/automat).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
In `dashfront`, state is managed outside the React component lifecycle using **Automats**. An Automat is an observable, fine-grained state store living in module scope.
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Module Scope**: State persists across route changes and component mount/unmount cycles.
|
||||
2. **Synchronous Reads**: Components read state synchronously in their `constructor` (or initial state) — zero loading flicker or blank frames.
|
||||
3. **Fine-Grained Subscriptions**: React `PureComponent` instances subscribe in `componentDidMount` with selector functions or key lists, skipping unnecessary re-renders.
|
||||
4. **Explicit Cleanup**: Every subscription returns an `unsub()` function called in `componentWillUnmount()`.
|
||||
5. **No Context / No Hooks in PureComponents**: Automats bypass React Context providers and hooks, eliminating reconciliation cascades.
|
||||
6. **Automatic Coordination**: Domain and configuration automats subscribe to [`authAutomat`](./authAutomat.md) to automatically bootstrap on login, switch data when the active company changes, and reset on logout.
|
||||
|
||||
## Domain & Data Model Guides
|
||||
|
||||
- [**Artikeltypen-Matrix**](./article-types-matrix.md): Spezifikation und Matrix für non-standard Artikeltypen (*Stückliste, Variations-Vater, Variations-Kind, Varianten-Baum*).
|
||||
|
||||
---
|
||||
|
||||
## Automats Directory
|
||||
|
||||
| Automat | State File | Documentation | Description |
|
||||
|---|---|---|---|
|
||||
| **`artikelAutomat`** | [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js) | [artikelAutomat.md](./artikelAutomat.md) | Cache and reactive distributor for single article detail payloads (`GET /api/article/:sku`) with inflight request deduplication. |
|
||||
| **`authAutomat`** | [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js) | [authAutomat.md](./authAutomat.md) | Manages authentication lifecycle, session bootstrap, Google One Tap login, and multi-tenant company selection. |
|
||||
| **`brandAutomat`** | [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) | [brandAutomat.md](./brandAutomat.md) | Company-scoped catalog brands / manufacturers list (`GET /api/brand`, upsert). Auto-loads on company auth. |
|
||||
| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), tree ordering, drag-and-drop moves, and image uploads. Triggers the bulk category-view preload once the tree succeeds. |
|
||||
| **`categoryViewAutomat`** | [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js) | [categoryViewAutomat.md](./categoryViewAutomat.md) | Per-category article views (`KeyedAutomat`) keyed by slug path; all views are warmed right after the tree loads. |
|
||||
| **`companyAutomat`** | [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js) | [companyAutomat.md](./companyAutomat.md) | Active company configuration, tax rates (standard, reduced), root category ID, and company profile info. |
|
||||
| **`deliveryEditorAutomat`** | [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js) | [deliveryEditorAutomat.md](./deliveryEditorAutomat.md) | Multi-line inbound delivery editor, OCR/PDF receipt reconciliation, supplier article auto-matching, and draft badge tracking. |
|
||||
| **`navigationAutomat`** | [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js) | [navigationAutomat.md](./navigationAutomat.md) | Tracks and restores last visited article SKU and delivery paths across top-level section navigation. |
|
||||
| **`posterminalAutomat`** | [`src/automats/posterminalAutomat.js`](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js) | [posterminalAutomat.md](./posterminalAutomat.md) | Point-of-Sale hardware terminals and enabled category assignments per terminal. |
|
||||
| **`supplierAutomat`** | [`src/automats/supplierAutomat.js`](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js) | [supplierAutomat.md](./supplierAutomat.md) | Company-scoped supplier directory (`GET /api/supplier`, create, update, delete) with inflight deduplication. |
|
||||
| **`unitAutomat`** | [`src/automats/unitAutomat.js`](file:///home/seb/src/dashfront/src/automats/unitAutomat.js) | [unitAutomat.md](./unitAutomat.md) | Standard units of measure (Stück, kg, Liter, etc.) for the active company (`GET /api/unit`, upsert, delete). |
|
||||
| **`userSettingsAutomat`** | [`src/automats/userSettingsAutomat.js`](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js) | [userSettingsAutomat.md](./userSettingsAutomat.md) | Client-side user UI preferences (e.g. viewer docking position). Persisted to IndexedDB (`persist: true`). |
|
||||
|
||||
---
|
||||
|
||||
## Canonical PureComponent Consumption Pattern
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
|
||||
|
||||
class ArticleHeader extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// 1. Synchronous read — no initial flash of empty state
|
||||
this.state = {
|
||||
article: getArticle(props.sku),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// 2. Subscribe with fine-grained selector
|
||||
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(this.props.sku));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// 3. Re-subscribe if props change
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
this.unsub?.();
|
||||
this.setState({ article: getArticle(this.props.sku) });
|
||||
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(this.props.sku));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// 4. Always unsubscribe
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article } = this.state;
|
||||
if (!article) return <div>Lädt...</div>;
|
||||
return <h1>{article.name}</h1>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
All registered automats are available on `window.__AUTOMATS__` and accessible via `Automat.get(name)`. In development mode, the docked **AutomatInspector** (`src/devtools/AutomatInspector.jsx`) renders live state, subscriber counts, and action triggers.
|
||||
139
docs/article-types-matrix.md
Normal file
139
docs/article-types-matrix.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Artikeltypen-Matrix (Non-Standard Articles Documentation)
|
||||
|
||||
Dieses Dokument definiert das Verhalten, die Geschäftsregeln und das Datenmodell für die verschiedenen Artikeltypen im Dash ERP/POS-System:
|
||||
- **Standard-Artikel** (einfacher / eigenständiger Artikel)
|
||||
- **Stückliste** (BOM / Set / Bundle / Baugruppe)
|
||||
- **Variations-Vater** (Parent Variant / Modell / Abstrakter Gruppenartikel)
|
||||
- **Variations-Kind** (Child Variant / Konkrete Ausprägung)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht & Matrix
|
||||
|
||||
| Kriterium / Frage | Standard-Artikel | Stückliste (BOM / Set) | Variations-Vater | Variations-Kind |
|
||||
| :--- | :---: | :---: | :---: | :---: |
|
||||
| **Kann auch Stückliste sein?** | ✅ Ja *(umwandelbar)* | ℹ️ *Ist Stückliste* (mehrstufig schachtelbar) | ❌ **Nein** *(System blockiert)* | ✅ **Ja** *(Kind-Variante als Bundle)* |
|
||||
| **Kann auch Variations-Vater sein?** | ✅ Ja *(aktivierbar)* | ❌ **Nein** *(System blockiert)* | ℹ️ *Ist Vater* | ✅ **Ja** *(als Zwischenknoten im Baum)* |
|
||||
| **Kann auch Variations-Kind sein?** | ✅ Ja *(zuordenbar)* | ⚠️ **Ja** *(als Kind-Stückliste)* | ✅ **Ja** *(als Zwischenknoten im Baum)* | ℹ️ *Ist Kind* |
|
||||
| **Kann Optionen haben?** (`optionslist`) | ✅ Ja | ✅ **Ja** *(koexistiert)* | ❌ **Nein** *(System blockiert)* | ✅ **Ja** *(als Blattknoten / Verkaufsartikel)* |
|
||||
| **Hat eigenen physischen Bestand?** | ✅ **Ja** | ⚠️ **Virtuell** *(Komponenten-Minimum)* / Montage | ❌ **Nein** *(auf Variantenebene)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
|
||||
| **Eigene Eigenschaften** *(Hersteller, Kategorie)* | ✅ **Ja** | ✅ **Ja** *(eigene Kategorie & Marke)* | ✅ **Ja** *(Basis-Attribute, vererbt nach unten)* | ⚠️ **Ja / Geerbt** *(vom Elternknoten)* |
|
||||
| **Hat Lieferanten?** (`articlesupplier`) | ✅ **Ja** | ✅ **Ja** *(wenn Set als 1 SKU bezogen wird)* | ❌ **Nein** *(auf Variantenebene)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
|
||||
| **Kann auf Beleg sein?** *(Kasse, Rechnung, etc.)* | ✅ **Ja** | ✅ **Ja** *(als Set-Position verkaufbar)* | ❌ **Nein** *(abstrakt, nicht buchbar)* | ⚠️ **Nur als Blattknoten** *(vollständig spezifiziert)* |
|
||||
| **Hat eigenen Preis?** (`price`-Tabelle) | ✅ **Ja** | ✅ **Ja** *(fester Bundle-Verkaufspreis)* | ❌ **Nein** *(wird auf Variantenebene geführt)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
|
||||
| **Hat individuelle Fotos?** (`articlepic`) | ✅ **Ja** | ✅ **Ja** *(Foto des Gesamt-Sets)* | ✅ **Ja** *(Modell-/Familienfoto)* | ✅ **Ja** *(variantenspezifisch z. B. Farbe Rot)* |
|
||||
|
||||
---
|
||||
|
||||
## 2. Varianten-Baum: Das Rollenmodell (Hierarchische Varianten)
|
||||
|
||||
Varianten bilden im System eine **Baum-Struktur** ab (z. B. für mehrdimensionale Varianten wie *Modell $\rightarrow$ Farbe $\rightarrow$ Größe*).
|
||||
|
||||
```text
|
||||
[Wurzel: T-Shirt Classic] <- Reiner Vaterartikel (is_parent = 1, parentarticle_id = NULL)
|
||||
│
|
||||
├── [Zwischenknoten: Rot] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1)
|
||||
│ ├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
|
||||
│ └── [Größe M] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
|
||||
│
|
||||
└── [Zwischenknoten: Blau] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1)
|
||||
├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
|
||||
└── [Größe L] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
|
||||
```
|
||||
|
||||
### Die zwei Zustände von Knoten im Varianten-Baum:
|
||||
|
||||
1. **Innere Knoten / Zwischenknoten (Vater & Kind zugleich)**:
|
||||
- Gruppieren weitere Unterausprägungen (z. B. Farbvariante „Rot“ mit untergeordneten Größen).
|
||||
- Sind noch **nicht vollständig spezifiziert** $\rightarrow$ verhalten sich wie Vaterartikel:
|
||||
- **Kein** eigener physischer Lagerbestand.
|
||||
- **Kein** eigener Preis.
|
||||
- **Kein** Lieferant.
|
||||
- **Nicht** direkt auf Belegen / Kasse buchbar.
|
||||
- **Fotos**: Dienen oft als Träger für Farbfotos, die von allen darunterliegenden Größen geerbt werden.
|
||||
|
||||
2. **Blattknoten (Reines Kind am Ende des Astes)**:
|
||||
- Vollständig spezifizierter physischer Verkaufs- und Einkaufsartikel.
|
||||
- Eigener Lagerbestand, Barcode/EAN, Lieferantenkonditionen, Verkaufspreise und Belegfähigkeit.
|
||||
|
||||
---
|
||||
|
||||
## 3. Detail-Spezifikation nach Artikeltyp
|
||||
|
||||
### A. Stückliste (BOM / Bill of Materials / Bundle / Set)
|
||||
*Technischer Bezug*: `article.articlelist_id -> articlelist`
|
||||
|
||||
* **Kann auch Stückliste sein?**:
|
||||
- **Ja (Mehrstufig)**: Eine Stückliste ist bereits eine Stückliste. Komponenten können ihrerseits Stücklisten sein (verschachtelte Baugruppen). Selbstreferenz / Zirkelbezug ist verboten.
|
||||
* **Kann auch Variations-Vater sein?**:
|
||||
- **Nein**: Im Frontend und Backend blockiert (`Ein Stücklisten-Artikel kann kein Vaterartikel sein.`). Ein Vater ist ein abstraktes Gruppierungselement, kein Bündel konkreter Teile.
|
||||
* **Kann auch Variations-Kind sein?**:
|
||||
- **Ja**: Ein Blattknoten im Variantenbaum kann als Stückliste definiert sein (z. B. *„Geschenkset T-Shirt – Größe L“* besteht aus *T-Shirt L + Box + Karte*).
|
||||
* **Kann Optionen haben?**:
|
||||
- **Ja**: `articlelist` und `optionslist` koexistieren harmonisch nebeneinander (z. B. PC-Set mit wählbaren Upgrades).
|
||||
* **Hat Bestand?**:
|
||||
- **Virtuell abgeleitet**: Ergibt sich dynamisch aus der kleinsten verfügbaren Komponentenmenge:
|
||||
$$\text{Bestand}_{\text{Set}} = \min_{i} \left( \left\lfloor \frac{\text{Bestand}_i}{\text{Menge}_i} \right\rfloor \right)$$
|
||||
Bei Verkauf werden die Komponenten aus dem Lager abgebucht. **Manuelle Bestandsanpassungen (`POST /api/stock/adjust`) auf Stücklisten sind gesperrt** (HTTP 400), um eine künstliche Komponenten-Inflation zu verhindern. Inventur- und Bestandskorrekturen erfolgen direkt an den physischen Komponenten.
|
||||
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
|
||||
- **Ja**: Eigenständige Zuweisung von Marke (`brand_id`), Kategorien (`categoryarticle`), Name und Beschreibung.
|
||||
* **Hat Lieferanten?**:
|
||||
- **Ja**: Wenn der Lieferant das fertige Set als **1 Artikel** liefert (z. B. Handelsset, Werkzeugkoffer oder Starterkit mit eigener Lieferanten-SKU und Set-EK). Bei interner Montage liegen die Lieferanten stattdessen auf den Komponenten.
|
||||
* **Kann auf Beleg sein?**:
|
||||
- **Ja**: Voll belegfähig auf Kasse (POS), Angebot, Auftrag, Lieferschein und Rechnung.
|
||||
* **Hat einen Preis?**:
|
||||
- **Ja**: Eigener Verkaufspreis in `price` (Set-Preis mit Steuer und Preisstaffeln).
|
||||
* **Hat individuelle Fotos?**:
|
||||
- **Ja**: Zeigt das Set/Bundle als Gesamtheit.
|
||||
|
||||
---
|
||||
|
||||
### B. Variations-Vater (Parent Variant Article)
|
||||
*Technischer Bezug*: `article.is_parent = 1`, `parentarticle_id = NULL` *(Wurzelknoten)*
|
||||
|
||||
* **Kann auch Stückliste sein?**:
|
||||
- **Nein**: Vaterartikel können keine Stückliste besitzen.
|
||||
* **Kann auch Variations-Vater sein?**:
|
||||
- **Ja**: Ist der Vater.
|
||||
* **Kann auch Variations-Kind sein?**:
|
||||
- **Ja, als Zwischenknoten**: Im Variantenbaum kann ein Vaterartikel seinerseits Kind eines übergeordneten Vaters sein (`parentarticle_id != NULL`).
|
||||
* **Kann Optionen haben?**:
|
||||
- **Nein**: Optionen werden erst an der konkreten verkaufbaren Variante angehängt.
|
||||
* **Hat Bestand?**:
|
||||
- **Nein**: Wird auf Variantenebene geführt.
|
||||
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
|
||||
- **Ja**: Definiert gemeinsame Basiseigenschaften (Hersteller, Hauptkategorie, Grundbeschreibung), die von den Kindern geerbt werden.
|
||||
* **Hat Lieferanten?**:
|
||||
- **Nein**: Lieferanten und EK-Preise werden auf Variantenebene gepflegt.
|
||||
* **Kann auf Beleg sein?**:
|
||||
- **Nein**: Da abstrakt (nicht greifbar / nicht ausgeliefert), kann ein Vater nie auf einem Beleg stehen.
|
||||
* **Hat einen Preis?**:
|
||||
- **Nein**: Eigene Preise existieren nur an den konkreten Ausprägungen (im Katalog ggf. nur abgeleitete Anzeige wie *„ab X €“*).
|
||||
* **Hat individuelle Fotos?**:
|
||||
- **Ja**: Dient als Haupt-/Familienbild im Produktkatalog.
|
||||
|
||||
---
|
||||
|
||||
### C. Variations-Kind (Child Variant Article)
|
||||
*Technischer Bezug*: `article.parentarticle_id -> article.id`
|
||||
|
||||
* **Kann auch Stückliste sein?**:
|
||||
- **Ja**: Als Blattknoten kann die Variante als Set aus mehreren Komponenten aufgebaut sein.
|
||||
* **Kann auch Variations-Vater sein?**:
|
||||
- **Ja**: Kann als Zwischenknoten im Baum weitere Unterausprägungen besitzen.
|
||||
* **Kann auch Variations-Kind sein?**:
|
||||
- **Ja**: Ist ein Kind des übergeordneten Knotens.
|
||||
* **Kann Optionen haben?**:
|
||||
- **Ja**: Als verkaufbarer Blattknoten können Optionen zugewiesen werden.
|
||||
* **Hat Bestand?**:
|
||||
- **Ja (als Blattknoten)**: Physischer Lagerbestand pro Lagerplatz mit Zu-/Abgangsbuchungen. *(Als Zwischenknoten: Nein).*
|
||||
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
|
||||
- **Ja / Geerbt**: Erbt standardmäßig Hersteller und Kategorie vom Elternknoten, kann aber abweichende Merkmale und Texte besitzen.
|
||||
* **Hat Lieferanten?**:
|
||||
- **Ja (als Blattknoten)**: Eigene Lieferantenbeziehungen, Lieferanten-SKU, Gebindegrößen (PU) und Einkaufspreise.
|
||||
* **Kann auf Beleg sein?**:
|
||||
- **Ja (als Blattknoten)**: Ist der eigentliche Verkaufsartikel mit Barcode/EAN für Kasse, Angebot, Lieferschein und Rechnung.
|
||||
* **Hat einen Preis?**:
|
||||
- **Ja (als Blattknoten)**: Eigene Preise, Steuerklassen, Staffelpreise und Kundengruppen-Preise.
|
||||
* **Hat individuelle Fotos?**:
|
||||
- **Ja**: Spezifisches Foto der Ausprägung (mit Fallback auf Elternknoten/Wurzel, falls keines vorhanden ist).
|
||||
196
docs/artikelAutomat.md
Normal file
196
docs/artikelAutomat.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# `artikelAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js)
|
||||
> **Registration Key**: `'artikel'` (`Automat.get('artikel')` / `window.__AUTOMATS__.get('artikel')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`artikelAutomat` serves as the centralized cache and reactive distributor for individual article detail payloads (`GET /api/article/:sku`).
|
||||
|
||||
### Core Responsibilities
|
||||
- **In-flight Deduplication**: Multiple components or route guards requesting the same SKU concurrently share a single HTTP request via an internal `Map<string, Promise<object>>`.
|
||||
- **Zero-Flicker Navigation**: Navigating between tabs or returning to previously viewed articles reuses the cached detail object instantly without round-trips.
|
||||
- **Optimistic Updates**: Immediate UI updates (such as renaming an article via `setArticleName`) without awaiting network round-trips.
|
||||
- **Granular Subscriptions**: Components can subscribe to an individual SKU via `createArticleSelector(sku)` rather than re-rendering on every cache change.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface ArtikelState {
|
||||
/**
|
||||
* Cached article detail payloads keyed by SKU string.
|
||||
* A missing key indicates the article is not yet loaded into memory.
|
||||
* Values include full article details: prices, photos, eans, brand, unit,
|
||||
* categoryIds, manifests, child variants, etc.
|
||||
*/
|
||||
bySku: Record<string, object>;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
bySku: {},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'artikel' });
|
||||
```
|
||||
|
||||
- **`name`**: `'artikel'` — registers the automat globally.
|
||||
- **`persist`**: `false` (default in-memory window lifecycle).
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `artikelAutomat.actions.<method>()`.
|
||||
|
||||
### `loadArticle(sku: string): Promise<object>`
|
||||
Loads an article detail into the cache. If already present in `bySku`, returns the cached article immediately. If an HTTP request for this SKU is currently in-flight, it returns that shared promise. Otherwise, dispatches `GET /api/article/:sku`, normalizes the response, stores it in `bySku`, and cleans up the in-flight map.
|
||||
|
||||
### `reloadArticle(sku: string): Promise<object>`
|
||||
Bypasses the cached copy and silently fetches a fresh detail payload from the backend. The existing cache entry is preserved until the response arrives. Typically used during SSE mutation events or user pull-to-refresh.
|
||||
|
||||
### `setArticle(article: object): object`
|
||||
Puts an article detail object directly into `bySku[article.sku]`. Requires `article` to be an object with a defined `sku`. Used after create/update API mutations so that all subscribers immediately observe the latest data.
|
||||
|
||||
### `setArticleName(sku: string, name: string): object | null`
|
||||
Optimistically updates the `name` property of a cached article without making an API call. Returns the updated article object or `null` if the SKU was not found in cache.
|
||||
|
||||
### `clearArticle(sku: string): void`
|
||||
Removes a specific SKU from `bySku`. Useful after deleting an article or when forcing a complete refetch on next access.
|
||||
|
||||
### `clear(): void`
|
||||
Resets the entire `bySku` dictionary to empty (`{}`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Selectors & Helper Functions
|
||||
|
||||
Exported alongside `artikelAutomat`:
|
||||
|
||||
### `getArticle(sku: string): object | null`
|
||||
Synchronous read from `artikelAutomat.state.bySku[sku]`. Returns `null` if not found or if `sku` is nullish.
|
||||
|
||||
```js
|
||||
const article = getArticle('10042');
|
||||
```
|
||||
|
||||
### `hasArticle(sku: string): boolean`
|
||||
Returns `true` if `sku` is already loaded in `bySku`.
|
||||
|
||||
```js
|
||||
if (!hasArticle('10042')) {
|
||||
void artikelAutomat.actions.loadArticle('10042');
|
||||
}
|
||||
```
|
||||
|
||||
### `createArticleSelector(sku: string): (state: ArtikelState) => object | null`
|
||||
Produces a memoized selector function for a specific SKU. Pass this to `artikelAutomat.subscribe(target, selector)` so your component only re-renders when this specific article's data changes.
|
||||
|
||||
### `selectArticle(sku: string): ScopedSlice<object | null>`
|
||||
Returns a scoped slice for the given SKU (`artikelAutomat.select(createArticleSelector(sku))`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Detail Header
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import artikelAutomat, { getArticle, createArticleSelector } from '../automats/artikelAutomat';
|
||||
|
||||
export class ArticleHeader extends PureComponent {
|
||||
static propTypes = {
|
||||
sku: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Synchronous initial state read prevents layout shift / loading spinner flicker
|
||||
this.state = {
|
||||
article: getArticle(props.sku),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.subscribe(this.props.sku);
|
||||
if (!this.state.article) {
|
||||
artikelAutomat.actions.loadArticle(this.props.sku).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
this.subscribe(this.props.sku);
|
||||
if (!hasArticle(this.props.sku)) {
|
||||
artikelAutomat.actions.loadArticle(this.props.sku).catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
subscribe(sku) {
|
||||
this.unsub?.();
|
||||
this.setState({ article: getArticle(sku) });
|
||||
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(sku));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article } = this.state;
|
||||
if (!article) return <div>Artikel wird geladen...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>{article.name}</h2>
|
||||
<span>SKU: {article.sku}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Inline Optimistic Rename
|
||||
|
||||
```js
|
||||
import { artikelAutomat } from '../automats';
|
||||
import { article as articleApi } from '../api';
|
||||
|
||||
async function updateName(sku, newName) {
|
||||
// 1. Optimistic update in UI cache
|
||||
artikelAutomat.actions.setArticleName(sku, newName);
|
||||
|
||||
try {
|
||||
// 2. Persist to backend
|
||||
const res = await articleApi.update(sku, { name: newName });
|
||||
// 3. Re-set authoritative response
|
||||
artikelAutomat.actions.setArticle(res.article);
|
||||
} catch (err) {
|
||||
// 4. Rollback on failure
|
||||
await artikelAutomat.actions.reloadArticle(sku);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices & Gotchas
|
||||
|
||||
1. **Always clean up subscriptions**: Save `this.unsub = artikelAutomat.subscribe(...)` and call `this.unsub()` in `componentWillUnmount`.
|
||||
2. **Use `createArticleSelector`**: Never subscribe an article view component with the raw `artikelAutomat.subscribe(this)` without a selector; otherwise, any other article loaded or modified anywhere in the app will trigger an unneeded re-render.
|
||||
3. **Do not mutate directly**: Never write `artikelAutomat.state.bySku[sku].name = 'foo'`. Always use `setArticle` or `setArticleName`.
|
||||
204
docs/authAutomat.md
Normal file
204
docs/authAutomat.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# `authAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js)
|
||||
> **Registration Key**: `'auth'` (`Automat.get('auth')` / `window.__AUTOMATS__.get('auth')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`authAutomat` manages the authentication and session lifecycle of the Dashfront client, as well as tenant company selection. It acts as the root coordinator for all other configuration and catalog automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`, `supplierAutomat`).
|
||||
|
||||
### Core Responsibilities
|
||||
- **Session Lifecycle**: Handles transitioning through `'loading'` → `'authed'` or `'anon'`.
|
||||
- **Google One Tap / GIS Integration**: Exchanging Google OAuth JWTs for session cookies via `/api/auth/google`.
|
||||
- **Multi-Tenant Company Switching**: Updating the active company context via `/api/auth/selected-company` and synchronizing user state.
|
||||
- **Event Dispatching**: Emits cross-subsystem events for session establishment, company changes, and logout.
|
||||
- **Automatic Sub-Store Bootstrap**: Downstream configuration automats subscribe to `authAutomat` to load or clear themselves reactively.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface AuthUser {
|
||||
id?: string | number;
|
||||
googleAuthId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
picture?: string | null;
|
||||
selectedCompanyId?: string | number | null;
|
||||
companies?: Array<{ id: string | number; name: string }>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
/** Authenticated user profile, or null when anonymous. */
|
||||
user: AuthUser | null;
|
||||
/** Google OAuth Client ID provided by backend bootstrap. */
|
||||
googleClientId: string | null;
|
||||
/** Current authentication lifecycle phase. */
|
||||
status: 'loading' | 'anon' | 'authed';
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
user: null,
|
||||
googleClientId: null,
|
||||
status: 'loading',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'auth' });
|
||||
```
|
||||
|
||||
- **`name`**: `'auth'`
|
||||
- **`persist`**: `false` (session token resides in secure HTTP-only cookies).
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle Events & Dispatcher
|
||||
|
||||
Constants exported from `authAutomat`:
|
||||
- **`AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished'`**: Fired when a valid session is recognized.
|
||||
- **`AUTH_COMPANY_CHANGED = 'auth/companyChanged'`**: Fired when the active company changes or a new company is created.
|
||||
- **`AUTH_LOGGED_OUT = 'auth/loggedOut'`**: Fired when user logs out.
|
||||
|
||||
### Event Dispatcher Registration
|
||||
```js
|
||||
import { setAuthEventDispatcher } from '../automats/authAutomat';
|
||||
|
||||
// Allows routing events to an external system, logger, or telemetry
|
||||
setAuthEventDispatcher((event) => {
|
||||
console.log('[Auth Event]', event.type);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Actions & Methods
|
||||
|
||||
Access actions via `authAutomat.actions.<method>()`.
|
||||
|
||||
### `bootstrapSession(session?: object): Promise<object>`
|
||||
Initializes the session on application startup:
|
||||
1. If `session` is passed directly, uses it; otherwise fetches `GET /api/auth/me`.
|
||||
2. Updates `googleClientId`.
|
||||
3. If no user is returned, sets status to `'anon'`.
|
||||
4. If a user is returned, merges the profile, normalizes base64 avatar pictures, sets status to `'authed'`, and emits `AUTH_SESSION_ESTABLISHED`.
|
||||
|
||||
### `loginWithGoogle(credential: string): Promise<object>`
|
||||
Submits a Google One Tap JWT to `POST /api/auth/google`. On success, updates user state, sets status to `'authed'`, and emits `AUTH_SESSION_ESTABLISHED`.
|
||||
|
||||
### `selectCompany(companyId: string | number): Promise<AuthUser>`
|
||||
Calls `PUT /api/auth/selected-company` to switch active company context in the backend session cookie. Updates `state.user.selectedCompanyId`, triggers `AUTH_COMPANY_CHANGED`, and returns the updated user.
|
||||
|
||||
### `createCompany(payload: object): Promise<AuthUser>`
|
||||
Creates a new tenant company via `POST /api/company` and automatically selects it for the session. Emits `AUTH_COMPANY_CHANGED`.
|
||||
|
||||
### `logout(): Promise<void>`
|
||||
Calls `POST /api/auth/logout`, replaces history state to `'/'`, clears user state, sets status to `'anon'`, and emits `AUTH_LOGGED_OUT`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Coordination
|
||||
|
||||
Downstream automats coordinate with `authAutomat` at module load time. For example:
|
||||
|
||||
```js
|
||||
// In companyAutomat.js / brandAutomat.js / unitAutomat.js / posterminalAutomat.js:
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void targetAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
targetAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
When `auth.status` transitions to `'authed'` with a `selectedCompanyId`, all dependent stores load their data. On logout (`'anon'`), they clear their state.
|
||||
|
||||
---
|
||||
|
||||
## 7. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Login Gate / User Menu
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
|
||||
export class UserMenu extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: authAutomat.state.user,
|
||||
status: authAutomat.state.status,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = authAutomat.subscribe(this, ['user', 'status']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
handleCompanyChange = (e) => {
|
||||
authAutomat.actions.selectCompany(e.target.value);
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
authAutomat.actions.logout();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user, status } = this.state;
|
||||
if (status === 'loading') return <div>Authentifizierung...</div>;
|
||||
if (status === 'anon' || !user) return <div>Nicht angemeldet</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{user.name} ({user.email})</span>
|
||||
<select value={user.selectedCompanyId ?? ''} onChange={this.handleCompanyChange}>
|
||||
{user.companies?.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={this.handleLogout}>Abmelden</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Application Bootstrap in `AuthBootstrap.jsx`
|
||||
|
||||
```js
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
|
||||
async function initApp() {
|
||||
try {
|
||||
await authAutomat.actions.bootstrapSession();
|
||||
} catch (err) {
|
||||
console.error('Session bootstrap failed:', err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Best Practices & Gotchas
|
||||
|
||||
1. **Never pass `googleAuthId`**: The frontend must never send `googleAuthId` in request bodies or query params. Identity is extracted from the session cookie on the server.
|
||||
2. **Handle `'loading'` State**: Do not render authentication-required views while `authAutomat.state.status === 'loading'`.
|
||||
3. **Picture normalization**: `authAutomat` automatically formats raw base64 picture strings into valid `data:image/...;base64,...` URIs.
|
||||
168
docs/brandAutomat.md
Normal file
168
docs/brandAutomat.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# `brandAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)
|
||||
> **Registration Key**: `'brand'` (`Automat.get('brand')` / `window.__AUTOMATS__.get('brand')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`brandAutomat` manages the active company's catalog brands (manufacturers). Brands are referenced by articles for catalog organization, filtering, and GPSR (General Product Safety Regulation) manufacturer compliance info.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Company Scoping**: Caches all brands for the currently selected company.
|
||||
- **Alphabetical Sorting**: Automatically sorts brands by name using German (`'de'`) collation.
|
||||
- **Autonomous Bootstrap**: Automatically triggers `load()` when `authAutomat` enters `'authed'` with a selected company, and calls `reset()` when the session ends.
|
||||
- **Upsert Mutation**: Handles creating or updating a brand with immediate in-memory cache synchronization.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface Brand {
|
||||
id: number | string;
|
||||
name: string;
|
||||
gsprpinfo?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface BrandState {
|
||||
/** Alphabetically sorted list of company brands. */
|
||||
brands: Array<Brand>;
|
||||
/** Async lifecycle status. */
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Error message if loading failed. */
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
brands: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'brand' });
|
||||
```
|
||||
|
||||
- **`name`**: `'brand'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `brandAutomat.actions.<method>()`.
|
||||
|
||||
### `load(): Promise<Array<Brand>>`
|
||||
Fetches all brands for the current company via `GET /api/brand`. Sorts them by name, updates `status` to `'succeeded'`, and returns the array. If the request fails, sets `status` to `'failed'` and records `error`.
|
||||
|
||||
### `upsert(body: { name: string, gsprpinfo?: string }): Promise<Brand>`
|
||||
Creates or updates a brand via `POST /api/brand/upsert`. Merges the updated brand into `state.brands`, re-sorts the list, updates state, and returns the modified brand object.
|
||||
|
||||
### `reset(): void`
|
||||
Resets the state back to `initialState` (`brands: []`, `status: 'idle'`, `error: null`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Automat Subscriptions
|
||||
|
||||
`brandAutomat` subscribes to `authAutomat` at module initialization:
|
||||
|
||||
```js
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void brandAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
brandAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Brand Selector Dropdown
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import brandAutomat from '../automats/brandAutomat';
|
||||
|
||||
export class BrandSelect extends PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
brands: brandAutomat.state.brands,
|
||||
status: brandAutomat.state.status,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = brandAutomat.subscribe(this, ['brands', 'status']);
|
||||
if (this.state.status === 'idle') {
|
||||
brandAutomat.actions.load().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { brands, status } = this.state;
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (status === 'loading') {
|
||||
return <select disabled><option>Marken laden...</option></select>;
|
||||
}
|
||||
|
||||
return (
|
||||
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">-- Keine Marke --</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Creating or Updating a Brand
|
||||
|
||||
```js
|
||||
import brandAutomat from '../automats/brandAutomat';
|
||||
|
||||
async function handleSaveBrand(name, gsprpinfo) {
|
||||
try {
|
||||
const brand = await brandAutomat.actions.upsert({ name, gsprpinfo });
|
||||
console.log('Saved brand:', brand);
|
||||
} catch (err) {
|
||||
console.error('Failed to save brand:', err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices & Gotchas
|
||||
|
||||
1. **Automatic Loading**: In normal usage, you rarely need to invoke `load()` manually; `authAutomat` triggers it as soon as the user logs in or switches company.
|
||||
2. **Alphabetical Guarantee**: The `brands` array in state is guaranteed to be sorted alphabetically (`localeCompare` with German rules).
|
||||
288
docs/categoryAutomat.md
Normal file
288
docs/categoryAutomat.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# `categoryAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)
|
||||
> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`)
|
||||
> **Related**: [`categoryViewAutomat`](./categoryViewAutomat.md) — the keyed per-category article views, extracted into their own module.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images and POS category visibility flags. It owns the tree and all category/article **mutations**; the per-category *article views* live in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and are attached to this automat via `attachCategoryTree()`.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Hierarchical Category Tree**: Normalized `id → node` dictionary (`categories`) representing the parent-child relational tree.
|
||||
- **Bulk View Preload**: after `GET /api/category/tree` succeeds, it triggers `loadAllCategoryViews()` so every category's articles are already cached when a route mounts.
|
||||
- **Mutations & Tree Reordering**: Category creation, batch creation, sibling reordering (`sort`), drag-and-drop hierarchy moves, and in-place renaming.
|
||||
- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the matching category views' article lists.
|
||||
- **Media Management**: Uploading and deleting category cover images and updating AVIF full and thumbnail hashes (`fullHash`, `thumbHash`).
|
||||
- **POS Integration**: Toggling POS visibility and hardware terminal IDs per category.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface CategoryNode {
|
||||
id: number | string;
|
||||
name: string;
|
||||
parentId: number | string | null;
|
||||
sort: number;
|
||||
pos?: boolean;
|
||||
fullHash?: string | null;
|
||||
thumbHash?: string | null;
|
||||
thumbW?: number | null;
|
||||
thumbH?: number | null;
|
||||
posterminalIds?: Array<number>;
|
||||
childCount?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CategoryState {
|
||||
companyId: number | string | null;
|
||||
languageId: string | null;
|
||||
/** Normalized category map keyed by string ID. */
|
||||
categories: Record<string, CategoryNode> | null;
|
||||
/** Root (home) category ID for the current company. */
|
||||
rootCategoryId: number | string | null;
|
||||
/** Whether category tiles are in rename/edit mode. */
|
||||
renameMode: boolean;
|
||||
/** Category tree loading status. */
|
||||
treeStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Last tree error message. */
|
||||
error: string | null;
|
||||
/** True while the post-tree preload of all category views is running. */
|
||||
viewsLoading: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
companyId: null,
|
||||
languageId: null,
|
||||
categories: null,
|
||||
rootCategoryId: null,
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
error: null,
|
||||
viewsLoading: false,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Category View Automats (delegated)
|
||||
|
||||
The keyed per-category article views are **no longer defined in this file**. They are implemented in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and re-exported here for backward compatibility:
|
||||
|
||||
| Symbol | Source |
|
||||
|---|---|
|
||||
| `categoryViews` | `categoryViewAutomat.js` |
|
||||
| `getOrCreateCategoryAutomat({ slugPath })` | `categoryViewAutomat.js` |
|
||||
| `invalidateAllCategoryViews()` | `categoryViewAutomat.js` |
|
||||
| `disposeAllCategoryViewAutomats()` | `categoryViewAutomat.js` |
|
||||
|
||||
The following facades remain on the tree automat and delegate to `categoryViews`:
|
||||
|
||||
### `categoryAutomat.key(slugPath): KeyHandle`
|
||||
Returns a scoped `KeyHandle` proxy for the given route slug.
|
||||
- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories`.
|
||||
- **Handle State**: `{ data: { slug, categoryId, articles, childCategories }, loaded: boolean, loading: boolean, error: string | null }`.
|
||||
- **`handle.load(options?)`**: Deduplicated background fetch across concurrent mounts.
|
||||
- **`handle.read()`**: React Suspense-compatible read (throws in-flight promise if unready).
|
||||
- **`handle.reload()`**: Re-fetches articles and child categories.
|
||||
- **`handle.subscribe(target, selector)`**: Fine-grained subscription to this slug view with zero cross-talk between different category routes.
|
||||
|
||||
See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `categoryAutomat.actions.<method>()`.
|
||||
|
||||
### Tree Operations
|
||||
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, updates state, and then **awaits `loadAllCategoryViews()`** with `viewsLoading: true → false`. Individual category failures are logged as a warning and do not fail the tree load.
|
||||
- **`loadAllCategoryViews(): Promise<{ loaded: number, failed: Array<{ slug, error }> }>`**: Re-runs the bulk view preload on demand (idempotent — already cached views resolve immediately).
|
||||
- **`setRenameMode(enabled: boolean): void`**: Toggles inline renaming mode for the category explorer.
|
||||
- **`reset(): void`**: Disposes all route automats and resets tree state to `initialState`.
|
||||
|
||||
### Category Mutations
|
||||
- **`createCategory({ parentId, name }): Promise<CategoryNode>`**: Creates a category via `POST /api/category` and inserts it into `categories`.
|
||||
- **`createCategories({ parentId, names }): Promise<CategoryNode[]>`**: Batch-creates multiple categories under `parentId`.
|
||||
- **`renameCategory({ categoryId, name }): Promise<CategoryNode>`**: Updates a category name via `PATCH /api/category/:id/name`. Protects root category from being renamed.
|
||||
- **`reorderCategories({ parentId, categoryIds }): Promise<number[]>`**: Updates sibling display ordering via `POST /api/category/:parentId/reorder`.
|
||||
- **`moveCategory({ categoryId, parentId }): Promise<object>`**: Moves a category under a new parent via `PATCH /api/category/:id/move`.
|
||||
- **`deleteCategory(categoryId): Promise<number|string>`**: Deletes a category via `DELETE /api/category/:id` and removes it from state.
|
||||
- **`toggleCategoryPos({ categoryId, pos }): Promise<CategoryNode>`**: Toggles POS visibility via `PATCH /api/category/:id/pos`.
|
||||
|
||||
### Article Mutations within Categories
|
||||
- **`createArticle({ parentId, name }): Promise<object>`**: Dispatches `POST /api/article` and appends the new article to the active category view automat's `articles` list immediately.
|
||||
- **`createArticles({ parentId, names }): Promise<object[]>`**: Batch creates articles and updates the active route view.
|
||||
- **`removeArticleBySku(sku: string): void`**: Removes an article from all live category view article lists when deleted.
|
||||
|
||||
### Category Media
|
||||
- **`uploadCategoryImage({ categoryId, file }): Promise<object>`**: Uploads an image blob to `POST /api/category/:id/image` and patches `fullHash`, `thumbHash`, `thumbW`, and `thumbH` in the category node.
|
||||
- **`deleteCategoryImage(categoryId): Promise<object>`**: Deletes the category image via `DELETE /api/category/:id/image`.
|
||||
|
||||
### Hardware POS Terminals
|
||||
- **`patchCategoryPosterminalIds({ categoryId, terminalId, enabled }): void`**: Updates the `posterminalIds` array on a category node locally.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exported Tree Utilities
|
||||
|
||||
Pure helper functions re-exported from `../utils/categoryUtils`:
|
||||
- **`ROOT_CATEGORY_NAME`**: Constant for the root category.
|
||||
- **`getCategoryBreadcrumbs(categories, categoryId): Array<CategoryNode>`**: Traverses parent pointers from node to root.
|
||||
- **`getCategoryChildren(categories, parentId): Array<CategoryNode>`**: Returns all immediate child nodes sorted by `sort`.
|
||||
- **`categoryDisplayName(category): string`**: Formats category display label.
|
||||
- **`categorySlug(name): string`**: Generates a clean URL slug.
|
||||
- **`normalizeCategoryNode(raw): CategoryNode`**: Normalizes API category payload.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Coordination
|
||||
|
||||
`categoryAutomat` automatically stays in sync with `companyAutomat` and `authAutomat`:
|
||||
|
||||
```js
|
||||
// Sync with companyAutomat:
|
||||
companyAutomat.subscribe((company) => {
|
||||
const id = company.current?.id ?? null;
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
_lastCompanyId = id;
|
||||
disposeAllCategoryViewAutomats();
|
||||
void categoryAutomat.actions.loadTree();
|
||||
}
|
||||
if (!id && _lastCompanyId) {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset on logout:
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Category Tree Browser
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import categoryAutomat, { getCategoryChildren, getCategoryBreadcrumbs } from '../automats/categoryAutomat';
|
||||
|
||||
export class CategoryBrowser extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
categories: categoryAutomat.state.categories,
|
||||
rootCategoryId: categoryAutomat.state.rootCategoryId,
|
||||
treeStatus: categoryAutomat.state.treeStatus,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = categoryAutomat.subscribe(this, ['categories', 'rootCategoryId', 'treeStatus']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { categories, rootCategoryId, treeStatus } = this.state;
|
||||
if (treeStatus === 'loading' || !categories) return <div>Lade Kategorien...</div>;
|
||||
|
||||
const currentParentId = this.props.categoryId ?? rootCategoryId;
|
||||
const subcategories = getCategoryChildren(categories, currentParentId);
|
||||
const breadcrumbs = getCategoryBreadcrumbs(categories, currentParentId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
{breadcrumbs.map((b) => (
|
||||
<span key={b.id}>{b.name} / </span>
|
||||
))}
|
||||
</nav>
|
||||
<ul>
|
||||
{subcategories.map((cat) => (
|
||||
<li key={cat.id}>{cat.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Consuming a Per-Route Category View with `categoryAutomat.key()`
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import categoryAutomat from '../automats/categoryAutomat';
|
||||
|
||||
export class CategoryArticlesList extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handle = categoryAutomat.key(props.slugPath);
|
||||
this.state = {
|
||||
articles: this.handle.state.data?.articles ?? [],
|
||||
childCategories: this.handle.state.data?.childCategories ?? [],
|
||||
loading: this.handle.state.loading,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = this.handle.subscribe(this, (item) => ({
|
||||
articles: item.data?.articles ?? [],
|
||||
childCategories: item.data?.childCategories ?? [],
|
||||
loading: item.loading,
|
||||
}));
|
||||
this.handle.load();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { articles, childCategories, loading } = this.state;
|
||||
if (loading) return <div>Lade Artikel in dieser Kategorie...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h3>Unterkategorien ({childCategories.length})</h3>
|
||||
<ul>
|
||||
{childCategories.map((cat) => (
|
||||
<li key={cat.id}>{cat.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Artikel ({articles.length})</h3>
|
||||
<ul>
|
||||
{articles.map((art) => (
|
||||
<li key={art.sku}>{art.name} ({art.sku})</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Best Practices & Gotchas
|
||||
|
||||
1. **Root category cannot be renamed or deleted**: The backend returns HTTP 400 if you attempt to rename or delete the company root category.
|
||||
2. **Reverse index updates**: When creating articles with `categoryAutomat.actions.createArticle`, any active route automat matching the parent ID is updated immediately.
|
||||
3. **Use normalized helpers**: Always use `getCategoryChildren(categories, parentId)` rather than manual filtering; it ensures correct sort order.
|
||||
136
docs/categoryViewAutomat.md
Normal file
136
docs/categoryViewAutomat.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# `categoryViewAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js)
|
||||
> **Registration Key**: `'category-views'` (`Automat.get('category-views')` / `window.__AUTOMATS__.get('category-views')`)
|
||||
> **Related**: [`categoryAutomat`](./categoryAutomat.md) — owns the category **tree** this module resolves against.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`categoryViewAutomat` owns the **per-category article views**: a `KeyedAutomat` (`categoryViews`) indexed by route **slug path** (`''` for root, `'getraenke'`, `'getraenke/softdrinks'`). Each entry holds one category's articles plus its child categories.
|
||||
|
||||
This module was extracted from [`categoryAutomat.js`](../src/automats/categoryAutomat.js) so the tree and the views can evolve independently. The dependency stays **one-directional**:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
CA[categoryAutomat<br/>tree + mutations] -->|attachCategoryTree| CV[categoryViewAutomat<br/>KeyedAutomat views]
|
||||
CV -->|loadAllCategoryViews| API[/api/category/:id/articles]
|
||||
CV -->|collectLoadedCategoryViewData| AI[articleIndexAutomat<br/>derived search index]
|
||||
```
|
||||
|
||||
`categoryAutomat` injects itself via `attachCategoryTree(automat)`; `categoryViewAutomat` never imports the tree, which avoids a circular import.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Slug → Category Resolution**: Resolves a slug path against the loaded tree to a `categoryId`.
|
||||
- **Per-Category Article Payloads**: `GET /api/category/:id/articles` for the resolved category.
|
||||
- **Bulk Warm-Up**: `loadAllCategoryViews()` loads **every** category in the tree (concurrency-bounded), triggered by `categoryAutomat.loadTree()` the moment the tree succeeds.
|
||||
- **Child Category Derivation**: Recomputes `childCategories` for all loaded views whenever the tree changes.
|
||||
- **Incremental Patches**: Appends created articles to, and removes deleted articles from, the affected views.
|
||||
|
||||
---
|
||||
|
||||
## 2. View Data Structure
|
||||
|
||||
```ts
|
||||
interface CategoryViewData {
|
||||
/** Cleaned slug path this view is keyed by. */
|
||||
slug: string;
|
||||
/** Resolved category id. */
|
||||
categoryId: number | string;
|
||||
/** Articles in that category. */
|
||||
articles: Array<ArticleRow>;
|
||||
/** Direct child category nodes, sorted by `sort`. */
|
||||
childCategories: Array<CategoryNode>;
|
||||
}
|
||||
```
|
||||
|
||||
Each keyed entry wraps this as `ItemState<CategoryViewData>`:
|
||||
`{ data, loading, loaded, error }`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Loading Strategy
|
||||
|
||||
### `loadAllCategoryViews({ concurrency }): Promise<{ loaded, failed }>`
|
||||
Builds the slug path of **every** category node from its breadcrumb trail
|
||||
(`categoryViewSlugPath()`), de-duplicates them, then loads each with
|
||||
`categoryViews.load(slug)`.
|
||||
|
||||
- **Already cached views resolve immediately** (`load()` short-circuits when `loaded && !error`), so the call is idempotent.
|
||||
- Concurrency defaults to **6**; failures are captured per category and returned in `failed` rather than rejecting the batch.
|
||||
- **Triggered by**: `categoryAutomat.actions.loadTree()` right after `treeStatus === 'succeeded'`, and available on demand via `categoryAutomat.actions.loadAllCategoryViews()`.
|
||||
|
||||
### `reloadLoadedCategoryViews(): Promise<void>`
|
||||
Re-fetches **only the already cached** views (`setDirty(undefined, false, true)`), keeping the cached set small for SSE-driven refreshes.
|
||||
|
||||
### `invalidateAllCategoryViews(): Promise<unknown[]>`
|
||||
Background-invalidates all loaded views. Called by `sseAutomat` on `'category'` and `'articles'` events.
|
||||
|
||||
### `disposeAllCategoryViewAutomats(): void`
|
||||
Clears every view and frees memory on company switch or logout.
|
||||
|
||||
### `waitForCategoryTree(): Promise<CategoryState>`
|
||||
Resolves once the attached tree reports `treeStatus === 'succeeded'`. Rejects on tree failure or when `authAutomat` turns `'anon'`, so a pending view never hangs forever.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exported API
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `categoryViews` (default) | The `KeyedAutomat` keyed by slug path. |
|
||||
| `attachCategoryTree(automat)` | Injects the tree automat (called once by `categoryAutomat`). |
|
||||
| `categoryViewSlugPath(categories, rootCategoryId, categoryId)` | Breadcrumb-derived slug path usable as a key. |
|
||||
| `collectLoadedCategoryViewData()` | All loaded view payloads — the source for the derived article index. |
|
||||
| `loadAllCategoryViews({ concurrency })` | Bulk warm-up of every category in the tree. |
|
||||
| `reloadLoadedCategoryViews()` | Re-fetch only the cached views. |
|
||||
| `invalidateAllCategoryViews()` | Background-invalidate all loaded views. |
|
||||
| `disposeAllCategoryViewAutomats()` | Clear all views. |
|
||||
| `getOrCreateCategoryAutomat({ slugPath })` | Returns the `KeyHandle` for a slug path. |
|
||||
| `refreshCategoryViewChildren(categories)` | Re-derive `childCategories` for every loaded view. |
|
||||
| `appendArticlesToCategoryViews({ categoryId, articles })` | Append newly created articles. |
|
||||
| `removeArticleBySku(sku)` | Strip a deleted article from every view. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Handle Usage
|
||||
|
||||
```js
|
||||
import categoryAutomat from '../automats/categoryAutomat';
|
||||
|
||||
const handle = categoryAutomat.key('getraenke/softdrinks');
|
||||
|
||||
handle.subscribe(this, (item) => ({
|
||||
articles: item.data?.articles ?? [],
|
||||
childCategories: item.data?.childCategories ?? [],
|
||||
}));
|
||||
|
||||
const data = await handle.load(); // deduplicated across concurrent mounts
|
||||
const data2 = handle.read(); // Suspense: throws the in-flight promise when unready
|
||||
handle.reload();
|
||||
handle.setDirty();
|
||||
```
|
||||
|
||||
All four facades (`key`, `read`, `load`, `views`) are attached to `categoryAutomat`
|
||||
as thin delegates to `categoryViews`, so existing import sites keep working.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Coordination
|
||||
|
||||
| Automat | Interaction |
|
||||
|---|---|
|
||||
| `categoryAutomat` | `attachCategoryTree()`, `loadAllCategoryViews()` after the tree succeeds, `refreshCategoryViewChildren()` on every tree mutation, `disposeAllCategoryViewAutomats()` on reset. |
|
||||
| `authAutomat` | `waitForCategoryTree()` rejects pending views on `'anon'`. |
|
||||
| `sseAutomat` | `invalidateAllCategoryViews()` on `'category'` / `'articles'` events. |
|
||||
| `articleIndexAutomat` | Subscribes to `categoryViews` and re-derives the search index from `collectLoadedCategoryViewData()`. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Bulk warm-up cost**: `loadTree()` now issues one request per category. On very large trees this is many requests — `concurrency` (default 6) is the throttle to tune. Individual failures are non-fatal.
|
||||
- **`viewsLoading`**: While it is `true`, the tree is ready but the views are still warming. Use it to decide between "render a spinner" and "render an empty grid".
|
||||
- **Keys are slug paths, not ids**: A rename or move changes the slug, so the view key changes and the old entry should be evicted; `refreshCategoryViewChildren()` handles the child lists, but stale keys may linger until the next `disposeAllCategoryViewAutomats()`.
|
||||
- **Do not import `categoryAutomat` from this file** — that would create the circular dependency the `attachCategoryTree()` injection exists to avoid.
|
||||
179
docs/companyAutomat.md
Normal file
179
docs/companyAutomat.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# `companyAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js)
|
||||
> **Registration Key**: `'company'` (`Automat.get('company')` / `window.__AUTOMATS__.get('company')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`companyAutomat` stores the active company configuration and metadata for the authenticated user's selected tenant. It provides essential company-wide parameters needed throughout Dashfront, such as default tax rates (standard and reduced), legal company details, default language, and the root category ID of the catalog hierarchy.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Company Configuration**: Caches legal details, tax rates (`taxrate`, `reducedtaxrate`), and catalog root category ID (`rootCategoryId`).
|
||||
- **Data Normalization**: Normalizes backend company payloads via `normalizeCompany()` for predictable shape in forms and calculations.
|
||||
- **Cross-Automat Triggering**: Changes in `companyAutomat` drive `categoryAutomat` to fetch the company's category tree.
|
||||
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` has an active `selectedCompanyId` and resets on logout.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface NormalizedCompany {
|
||||
id: number | string;
|
||||
name: string;
|
||||
rootCategoryId: number | string | null;
|
||||
taxrate: number; // Standard VAT rate (e.g. 19.00)
|
||||
reducedtaxrate: number; // Reduced VAT rate (e.g. 7.00)
|
||||
iso639: string; // Default language code (e.g. 'de')
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
taxId?: string;
|
||||
vatId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CompanyState {
|
||||
/** Normalized details of currently selected company. */
|
||||
current: NormalizedCompany | null;
|
||||
/** Async lifecycle status. */
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Last error message if load/update failed. */
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
current: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'company' });
|
||||
```
|
||||
|
||||
- **`name`**: `'company'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `companyAutomat.actions.<method>()`.
|
||||
|
||||
### `load(): Promise<NormalizedCompany | null>`
|
||||
Determines the selected company ID from `authAutomat.state.user?.selectedCompanyId`. If none is selected, returns `null`. Otherwise, queries `GET /api/company/:id`, normalizes the data, updates `state.current`, and sets status to `'succeeded'`.
|
||||
|
||||
### `update(payload: object): Promise<NormalizedCompany>`
|
||||
Persists updated company settings via `PUT /api/company/:id`, re-fetches authoritative data, normalizes it, updates `state.current`, and returns the updated company object.
|
||||
|
||||
### `reset(): void`
|
||||
Resets state back to `initialState`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exported Standalone Selectors
|
||||
|
||||
Exported plain helper functions for querying company state without requiring a React component:
|
||||
|
||||
| Selector | Return Type | Description |
|
||||
|---|---|---|
|
||||
| **`selectCurrentCompany()`** | `NormalizedCompany \| null` | Returns the entire current company object. |
|
||||
| **`selectCompanyId()`** | `string \| number \| null` | The ID of the currently selected company. |
|
||||
| **`selectCompanyStatus()`** | `'idle'\|'loading'\|'succeeded'\|'failed'` | Current status of the company store. |
|
||||
| **`selectCompanyName()`** | `string \| undefined` | Company legal or trade name. |
|
||||
| **`selectCompanyIso639()`** | `string \| undefined` | ISO language code (e.g. `'de'`). |
|
||||
| **`selectCompanyTaxrate()`** | `number \| null` | Standard VAT rate (e.g. `19.0`). |
|
||||
| **`selectCompanyReducedTaxrate()`** | `number \| null` | Reduced VAT rate (e.g. `7.0`). |
|
||||
| **`selectCompanyRootCategoryId()`** | `number \| string \| null` | ID of the root category node. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Subscriptions
|
||||
|
||||
`companyAutomat` automatically tracks authentication changes:
|
||||
|
||||
```js
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void companyAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
companyAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Tax Rate Calculation
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import companyAutomat, { selectCompanyTaxrate } from '../automats/companyAutomat';
|
||||
|
||||
export class PriceDisplay extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
taxrate: selectCompanyTaxrate() ?? 19.0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||
taxrate: state.current?.taxrate ?? 19.0,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { netPrice } = this.props;
|
||||
const grossPrice = netPrice * (1 + this.state.taxrate / 100);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>Netto: {netPrice.toFixed(2)} €</span>
|
||||
<strong>Brutto (inkl. {this.state.taxrate}% MwSt): {grossPrice.toFixed(2)} €</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Pure Function / Utility Usage
|
||||
|
||||
```js
|
||||
import { selectCompanyId, selectCompanyRootCategoryId } from '../automats/companyAutomat';
|
||||
|
||||
export function getCompanyUploadUrl() {
|
||||
const companyId = selectCompanyId();
|
||||
if (!companyId) throw new Error('Kein Unternehmen aktiv.');
|
||||
return `/api/company/${companyId}/upload`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Best Practices & Gotchas
|
||||
|
||||
1. **Tax Calculations**: Always query `selectCompanyTaxrate()` or `selectCompanyReducedTaxrate()` instead of hardcoding `19%` or `7%`.
|
||||
2. **Catalog Roots**: Don't assume root category is `1` or `null`; always use `selectCompanyRootCategoryId()`.
|
||||
237
docs/deliveryEditorAutomat.md
Normal file
237
docs/deliveryEditorAutomat.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# `deliveryEditorAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js)
|
||||
> **Registration Keys**: `'delivery-draft'` (draft editor) / `'delivery-{docId}'` (saved delivery editors)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`deliveryEditorAutomat` powers the inbound delivery reception, receipt reconciliation, and invoice editor (`/lieferung/neu` and `/lieferung/:deliveryId`). It manages complex multi-line receipts, OCR attachments, and automatic matching of supplier articles to internal catalog SKUs.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Multi-Instance Architecture**: Supports an unsaved draft editor (`deliveryDraftAutomat`) as well as isolated, named automat instances for each open delivery (`getDeliveryEditorAutomat(docId)`).
|
||||
- **Line Item Management**: Adding, editing, reordering, and deleting delivery lineitems (`name`, `sku`, `amount`, `pu`, `unit`, `netprice`, `taxrate`).
|
||||
- **Supplier Article Auto-Matching**: As lines are typed or parsed from receipts, automatically queries `GET /api/supplier/:id/articles` and indexes them by supplier SKU, internal SKU, and barcode EANs.
|
||||
- **Unlinking & Re-matching**: Supports unlinking mistaken supplier-article associations (`unlinkSupplierArticle`) and refreshing lines.
|
||||
- **Attachment & Receipt Pipelines**: Manages uploaded PDFs and image receipts, OCR parsing state, and attachment previews.
|
||||
- **Reactive Navigation Badges**: Used by `src/nav.js` to show live uncommitted line badges on the sidebar navigation items.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface DeliveryLine {
|
||||
key: string | number; // Stable client-side unique identifier
|
||||
name: string | null; // Article description
|
||||
sku: string | number | null; // Supplier SKU or internal SKU
|
||||
amount: number | null; // Delivered quantity
|
||||
pu: number | null; // Price unit (e.g. 1, 100)
|
||||
unit: string | null; // Unit of measure (e.g. 'Stück', 'kg')
|
||||
netprice: number | null; // Net unit price
|
||||
taxrate: number | null; // VAT rate (e.g. 19.0)
|
||||
}
|
||||
|
||||
interface SupplierArticle {
|
||||
id?: string | number;
|
||||
sku?: string | number | null;
|
||||
article?: {
|
||||
sku?: string | number | null;
|
||||
eans?: Array<string | number>;
|
||||
articleean?: Array<string | number>;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeliveryEditorState {
|
||||
deliveryEntities: Array<object>; // Available suppliers/parties
|
||||
id: string | number | null;
|
||||
deliveryId: string | number | null; // Alias of id
|
||||
date: string | null; // ISO delivery date string
|
||||
deliveryEntity: string | number | null; // Selected supplier ID
|
||||
partyId: string | number | null; // Alias of deliveryEntity
|
||||
externalNumber: string | null; // Supplier invoice / delivery slip number
|
||||
external_number: string | null; // Alias of externalNumber
|
||||
number: string | null; // Internal receipt number
|
||||
type: string | null;
|
||||
lines: Array<DeliveryLine>; // Editable line items
|
||||
lineitems: Array<DeliveryLine>; // Alias of lines
|
||||
notes: Array<any>; // Delivery notes
|
||||
attachments: Array<object>; // PDF/image attachments
|
||||
files: Array<object>; // Alias of attachments
|
||||
supplierArticles: Array<SupplierArticle>; // Loaded supplier articles
|
||||
supplierArticlesMap: Record<string, SupplierArticle>; // Lookup map by SKU / EAN
|
||||
supplierArticlesByLineKey: Record<string, SupplierArticle | null>; // Matched article per line
|
||||
supplierArticlesLoading: boolean; // Pending article lookup
|
||||
loadingLineKeys: Record<string, boolean>; // Per-line loading state
|
||||
busySupplierArticleLineKeys: Record<string, boolean>; // Lines with pending unlinks
|
||||
supplierArticlesError: string | null;
|
||||
unmatchedSupplierName: string | null; // Parsed supplier name without match
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Factory & Instance Resolution
|
||||
|
||||
### `createDeliveryEditorAutomat(options?: { name?: string } | string): Automat<DeliveryEditorState>`
|
||||
Factory function creating a new delivery editor automat or retrieving an existing one by name.
|
||||
|
||||
### `deliveryDraftAutomat` / `deliveryEditorAutomat`
|
||||
The singleton automat with name `'delivery-draft'` used for creating new, unsaved deliveries (`/lieferung/neu`).
|
||||
|
||||
### `getDeliveryEditorAutomat(docId: string | number | null): Automat<DeliveryEditorState>`
|
||||
Returns `deliveryDraftAutomat` when `docId` is null/undefined. If `docId` is provided, returns or instantiates `'delivery-${docId}'`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Helper Utilities
|
||||
|
||||
- **`createEmptyLineitem(line?: Partial<DeliveryLine>): DeliveryLine`**: Returns a normalized lineitem with a generated unique `key`.
|
||||
- **`buildSupplierArticlesMap(articles, existingMap?): Record<string, SupplierArticle>`**: Indexes articles by supplier SKU, internal SKU, and EANs (storing both original and lowercased keys for case-insensitive lookup).
|
||||
|
||||
---
|
||||
|
||||
## 5. Actions & Methods
|
||||
|
||||
Actions can be invoked directly from the automat instance:
|
||||
|
||||
### Header & Supplier Details
|
||||
- **`setId(id)`**: Sets `id` and `deliveryId`.
|
||||
- **`setDeliveryEntities(entities)`**: Sets available supplier entities dropdown list.
|
||||
- **`setDelivery(delivery)`**: Replaces the full delivery state, normalizes aliases (`lines`/`lineitems`, `attachments`/`files`), and immediately triggers `fetchSupplierArticles` if a supplier is set.
|
||||
- **`setDate(date)`**: Sets delivery date.
|
||||
- **`setDeliveryEntity(entityId)` / `setPartyId(partyId)`**: Sets the active supplier and refreshes supplier articles for the current lines.
|
||||
- **`setExternalNumber(externalNumber)`**: Sets external supplier invoice/slip number.
|
||||
- **`setNumber(number)`**: Sets internal receipt number.
|
||||
- **`setType(type)`**: Sets delivery document type.
|
||||
- **`setNotes(notes)`**: Sets delivery notes array.
|
||||
- **`setUnmatchedSupplierName(name)`**: Sets OCR-extracted supplier name before an entity is matched.
|
||||
|
||||
### Line Items
|
||||
- **`setLines(lines)` / `setLineitems(lines)`**: Sets the entire lines array.
|
||||
- **`addLineitem(line?)` / `addLine(line?)`**: Appends a new line item.
|
||||
- **`updateLineitem(payload)` / `updateLine(payload)`**: Updates line fields identified by `index` or `key`.
|
||||
- **`removeLineitem(payload)` / `removeLine(payload)`**: Deletes a line by index or key, and cleans up its matched supplier article entry.
|
||||
|
||||
### Supplier Article Matching & Unlinking
|
||||
- **`fetchSupplierArticles(targetEntityId?, targetLines?): Promise<void>`**: Fetches all supplier articles for the unique SKUs across the lines and builds the matching map.
|
||||
- **`fetchSupplierArticleForSku(sku, lineKey?): Promise<void>`**: Loads and matches a supplier article for a single line SKU.
|
||||
- **`unlinkSupplierArticle(internalSku, assignmentId, lineKey?): Promise<void>`**: Calls `DELETE /api/article/:sku/supplier/:id` to unassign an incorrect supplier article match, cleans up the map, and re-resolves the line.
|
||||
|
||||
### Attachments & Resets
|
||||
- **`addAttachment(attachment)`**: Appends a file/receipt attachment.
|
||||
- **`setAttachments(attachments)`**: Replaces the attachments array.
|
||||
- **`updateAttachment(payload)`**: Updates attachment fields by `id`.
|
||||
- **`removeAttachment(idOrPayload)`**: Removes an attachment by ID or index.
|
||||
- **`resetDelivery()`**: Clears delivery fields while preserving available supplier entities.
|
||||
- **`resetDeliveryEditor()`**: Resets the complete state back to `initialState`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exported Selectors
|
||||
|
||||
Plain selectors operating on `deliveryEditorAutomat.state` by default:
|
||||
|
||||
```js
|
||||
selectDeliveryId(state?)
|
||||
selectDeliveryEntities(state?)
|
||||
selectDeliveryDate(state?)
|
||||
selectDeliveryEntity(state?)
|
||||
selectUnmatchedSupplierName(state?)
|
||||
selectDeliveryExternalNumber(state?)
|
||||
selectDeliveryNumber(state?)
|
||||
selectDeliveryType(state?)
|
||||
selectDeliveryLineitems(state?)
|
||||
selectDeliveryNotes(state?)
|
||||
selectDeliveryAttachments(state?)
|
||||
selectSupplierArticles(state?)
|
||||
selectSupplierArticlesMap(state?)
|
||||
selectSupplierArticlesByLineKey(state?)
|
||||
selectSupplierArticlesLoading(state?)
|
||||
selectLoadingLineKeys(state?)
|
||||
selectBusySupplierArticleLineKeys(state?)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Delivery Lines Table
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import deliveryEditorAutomat from '../automats/deliveryEditorAutomat';
|
||||
|
||||
export class DeliveryLinesTable extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
lines: deliveryEditorAutomat.state.lines,
|
||||
matches: deliveryEditorAutomat.state.supplierArticlesByLineKey,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => ({
|
||||
lines: state.lines,
|
||||
matches: state.supplierArticlesByLineKey,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
handleAddLine = () => {
|
||||
deliveryEditorAutomat.actions.addLineitem({ amount: 1 });
|
||||
};
|
||||
|
||||
handleSkuChange = (key, sku) => {
|
||||
deliveryEditorAutomat.actions.updateLineitem({ key, sku });
|
||||
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(sku, key);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { lines, matches } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Artikelname</th>
|
||||
<th>Menge</th>
|
||||
<th>Zugeordneter Artikel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((l) => (
|
||||
<tr key={l.key}>
|
||||
<td>
|
||||
<input
|
||||
value={l.sku ?? ''}
|
||||
onChange={(e) => this.handleSkuChange(l.key, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>{l.name}</td>
|
||||
<td>{l.amount}</td>
|
||||
<td>{matches[l.key]?.article?.sku ?? 'Nicht zugeordnet'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={this.handleAddLine}>+ Position hinzufügen</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Best Practices & Gotchas
|
||||
|
||||
1. **Always use line `key`**: Line indices shift when rows are deleted or reordered. Use `l.key` generated by `createEmptyLineitem()` for React keys and lookup indices.
|
||||
2. **Draft vs Saved Delivery**: Always use `getDeliveryEditorAutomat(deliveryId)` when editing an existing delivery so edits don't contaminate the `/lieferung/neu` draft state.
|
||||
3. **Sidebar badge count**: `src/nav.js` directly inspects `deliveryEditorAutomat.state.lines.length` to render draft item badges.
|
||||
115
docs/navigationAutomat.md
Normal file
115
docs/navigationAutomat.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `navigationAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js)
|
||||
> **Registration Key**: `'navigation'` (`Automat.get('navigation')` / `window.__AUTOMATS__.get('navigation')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`navigationAutomat` tracks the user's most recently visited delivery document path and article SKU. When navigating across main sidebar sections (for example, switching from `/lieferung` to `/artikel` and back), it enables the navigation system to restore the user's active context instead of resetting them back to the root list page.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Context Preservation**: Remembers the exact active subpath (e.g. `/lieferung/42` or `/lieferung/neu`) and article SKU (e.g. `'SKU-8910'`).
|
||||
- **Seamless Multitasking**: Users working on a delivery who need to quickly check an article warehouse stock can navigate to `/artikel` and return to `/lieferung` without losing their spot.
|
||||
- **Session-Scoped History**: In-memory state (`persist: false`) kept during active application usage.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface NavigationState {
|
||||
/** Last delivery path visited, e.g. '/lieferung/19' or '/lieferung/neu'. */
|
||||
lastDeliveryPath: string | null;
|
||||
/** Last article SKU visited, e.g. '10042'. */
|
||||
lastArticleSku: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
lastDeliveryPath: null,
|
||||
lastArticleSku: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, {
|
||||
name: 'navigation',
|
||||
persist: false,
|
||||
});
|
||||
```
|
||||
|
||||
- **`name`**: `'navigation'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Actions are exported both on `navigationAutomat.actions` and as standalone named functions:
|
||||
|
||||
### `setLastDeliveryPath(path: string | null): void`
|
||||
Stores the full delivery route path (e.g. `'/lieferung/19'`, `'/lieferung/neu'`).
|
||||
|
||||
### `setLastArticleSku(sku: string | null): void`
|
||||
Stores the SKU of the article currently being edited or viewed.
|
||||
|
||||
### `clearNavigation(): void`
|
||||
Resets both `lastDeliveryPath` and `lastArticleSku` to `null`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Usage Examples
|
||||
|
||||
### A. Recording Active SKU in `ArtikelSkuPage`
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import { setLastArticleSku } from '../automats/navigationAutomat';
|
||||
|
||||
export class ArtikelSkuPage extends PureComponent {
|
||||
componentDidMount() {
|
||||
setLastArticleSku(this.props.sku);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
setLastArticleSku(this.props.sku);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>Artikel Detail für SKU {this.props.sku}</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Dynamic Navigation Link in `src/nav.js`
|
||||
|
||||
```js
|
||||
import navigationAutomat from './automats/navigationAutomat';
|
||||
|
||||
export function getDeliveryNavPath() {
|
||||
const lastPath = navigationAutomat.state.lastDeliveryPath;
|
||||
return lastPath || '/lieferung';
|
||||
}
|
||||
|
||||
export function getArticleNavPath() {
|
||||
const lastSku = navigationAutomat.state.lastArticleSku;
|
||||
return lastSku ? `/artikel/${encodeURIComponent(lastSku)}` : '/artikel';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Best Practices & Gotchas
|
||||
|
||||
1. **Path Formatting**: Always pass normalized route paths to `setLastDeliveryPath` (with leading slash).
|
||||
2. **Clear on Logout**: If resetting application state on logout, call `clearNavigation()`.
|
||||
185
docs/posterminalAutomat.md
Normal file
185
docs/posterminalAutomat.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# `posterminalAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/posterminalAutomat.js`](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js)
|
||||
> **Registration Key**: `'posterminal'` (`Automat.get('posterminal')` / `window.__AUTOMATS__.get('posterminal')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`posterminalAutomat` manages hardware Point-of-Sale (POS) terminals registered for the active company. In Dashfront, POS terminals can have specific category subsets enabled or disabled to control which products appear on specific physical checkouts or handheld POS devices.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Terminal Registry**: Lists all POS terminals (`id`, `name`, `categoryIds`).
|
||||
- **Category Filter Management**: Enabling or disabling categories for individual terminals (`enableAll`, `disableAll`, `enableCategory`, `disableCategory`).
|
||||
- **Terminal CRUD**: Creating new hardware terminal entries and deleting retired ones.
|
||||
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` authenticates with a company and resets on logout.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface PosTerminal {
|
||||
id: number;
|
||||
name: string;
|
||||
categoryIds?: Array<number>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface PosterminalState {
|
||||
/** Registered POS terminals for active company. */
|
||||
terminals: Array<PosTerminal>;
|
||||
/** Company ID these terminals belong to. */
|
||||
companyId: number | string | null;
|
||||
/** Async lifecycle status. */
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Last error message if load failed. */
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
terminals: [],
|
||||
companyId: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'posterminal' });
|
||||
```
|
||||
|
||||
- **`name`**: `'posterminal'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `posterminalAutomat.actions.<method>()`.
|
||||
|
||||
### `load(): Promise<Array<PosTerminal>>`
|
||||
Determines the selected company ID from `authAutomat` or `companyAutomat`. Fetches terminals via `GET /api/posterminal`, updates state, and returns the terminals list.
|
||||
|
||||
### `create({ name: string }): Promise<PosTerminal>`
|
||||
Creates a new POS terminal via `POST /api/posterminal` and appends it to `state.terminals`.
|
||||
|
||||
### `delete(terminalId: number): Promise<void>`
|
||||
Deletes a terminal via `DELETE /api/posterminal/:id` and filters it out of `state.terminals`.
|
||||
|
||||
### `enableAll({ terminalId: number }): Promise<object>`
|
||||
Enables all categories on the given terminal via `POST /api/posterminal/:id/category/enable-all`. Patches `categoryIds` on the terminal in state.
|
||||
|
||||
### `disableAll({ terminalId: number }): Promise<object>`
|
||||
Disables all categories on the given terminal via `POST /api/posterminal/:id/category/disable-all`. Patches `categoryIds` on the terminal in state.
|
||||
|
||||
### `enableCategory({ terminalId: number, categoryId: number }): Promise<object>`
|
||||
Enables a specific category on the given terminal via `POST /api/posterminal/:id/category/:catId/enable`.
|
||||
|
||||
### `disableCategory({ terminalId: number, categoryId: number }): Promise<object>`
|
||||
Disables a specific category on the given terminal via `DELETE /api/posterminal/:id/category/:catId/disable`.
|
||||
|
||||
### `reset(): void`
|
||||
Resets the automat back to `initialState`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exported Standalone Selectors
|
||||
|
||||
- **`selectPosterminals()`**: `() => Array<PosTerminal>` — Returns the list of registered terminals.
|
||||
- **`selectPosterminalStatus()`**: `() => string` — Returns the current loading status.
|
||||
- **`selectHasPosterminals()`**: `() => boolean` — Returns `true` if one or more terminals exist.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Subscriptions
|
||||
|
||||
`posterminalAutomat` coordinates with `authAutomat` at module load:
|
||||
|
||||
```js
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void posterminalAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
posterminalAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Terminal Management List
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import posterminalAutomat, { selectPosterminals } from '../automats/posterminalAutomat';
|
||||
|
||||
export class TerminalSettings extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
terminals: selectPosterminals(),
|
||||
status: posterminalAutomat.state.status,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = posterminalAutomat.subscribe(this, ['terminals', 'status']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
handleCreate = async () => {
|
||||
const name = window.prompt('Name des neuen POS-Terminals:');
|
||||
if (name) {
|
||||
await posterminalAutomat.actions.create({ name });
|
||||
}
|
||||
};
|
||||
|
||||
handleDelete = async (terminalId) => {
|
||||
if (window.confirm('Terminal wirklich löschen?')) {
|
||||
await posterminalAutomat.actions.delete(terminalId);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { terminals, status } = this.state;
|
||||
if (status === 'loading') return <div>Lade POS-Terminals...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Registrierte POS-Terminals</h3>
|
||||
<ul>
|
||||
{terminals.map((t) => (
|
||||
<li key={t.id}>
|
||||
<strong>{t.name}</strong> (ID: {t.id})
|
||||
<button onClick={() => this.handleDelete(t.id)}>Löschen</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={this.handleCreate}>+ Neues Terminal anlegen</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Best Practices & Gotchas
|
||||
|
||||
1. **Category synchronization**: Terminal category assignments are also referenced inside `categoryAutomat.actions.patchCategoryPosterminalIds`. Both stores stay in sync when configuring POS terminals.
|
||||
2. **Terminal IDs**: Use integer IDs when calling `delete`, `enableCategory`, or `disableCategory`.
|
||||
192
docs/supplierAutomat.md
Normal file
192
docs/supplierAutomat.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# `supplierAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/supplierAutomat.js`](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js)
|
||||
> **Registration Key**: `'supplier'` (`Automat.get('supplier')` / `window.__AUTOMATS__.get('supplier')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`supplierAutomat` manages the supplier directory for the active company (`GET /api/supplier`). Suppliers are used when receiving deliveries, reconciling purchase invoices, and organizing supplier-specific article catalogs.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Company Scoping & Auth Sync**: Caches suppliers for the selected company and automatically invalidates or re-fetches when switching companies or logging out.
|
||||
- **In-flight Deduplication**: Multiple components invoking `load()` concurrently share a single HTTP request promise (`inflightLoad`).
|
||||
- **Full CRUD Support**: Creating, updating (renaming), and deleting suppliers with automatic cache refresh.
|
||||
- **Silent Background Refreshes**: `reload()` updates supplier lists in the background without clearing current state.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface Supplier {
|
||||
id: string | number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface SupplierState {
|
||||
/** Company suppliers list. */
|
||||
suppliers: Array<Supplier>;
|
||||
/** Async lifecycle phase. */
|
||||
status: 'idle' | 'loading' | 'ready' | 'failed';
|
||||
/** Last error object if load failed. */
|
||||
error: Error | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
suppliers: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'supplier' });
|
||||
```
|
||||
|
||||
- **`name`**: `'supplier'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `supplierAutomat.actions.<method>()`.
|
||||
|
||||
### `load(): Promise<Array<Supplier>>`
|
||||
Loads the supplier list via `GET /api/supplier`. Deduplicates concurrent callers: if a request is already in flight, it returns the existing promise. Sets `status` to `'ready'` on success.
|
||||
|
||||
### `reload(): Promise<Array<Supplier>>`
|
||||
Silently re-fetches the supplier list without setting `status` back to `'loading'`. Preserves the current list until the new response arrives.
|
||||
|
||||
### `create(body: { name: string }): Promise<Supplier>`
|
||||
Creates a new supplier via `POST /api/supplier`, waits for `reload()`, and returns the newly created supplier object.
|
||||
|
||||
### `update(id: string | number, body: { name: string }): Promise<Supplier>`
|
||||
Updates supplier details via `PATCH /api/supplier/:id`, waits for `reload()`, and returns the updated supplier.
|
||||
|
||||
### `remove(id: string | number): Promise<void>`
|
||||
Deletes a supplier via `DELETE /api/supplier/:id` and triggers `reload()`.
|
||||
|
||||
### `setSuppliers(suppliers: Array<Supplier>): void`
|
||||
Directly replaces the cached supplier array without a network round-trip.
|
||||
|
||||
### `clear(): void`
|
||||
Resets the state back to `initialState`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Automat Subscriptions
|
||||
|
||||
`supplierAutomat` tracks `authAutomat` through `syncWithAuth`:
|
||||
|
||||
```js
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
Whenever the user authenticates with a company, suppliers load automatically; when logging out, they clear.
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Supplier Dropdown
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import supplierAutomat from '../automats/supplierAutomat';
|
||||
|
||||
export class SupplierDropdown extends PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
suppliers: supplierAutomat.state.suppliers,
|
||||
status: supplierAutomat.state.status,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = supplierAutomat.subscribe(this, ['suppliers', 'status']);
|
||||
if (this.state.status === 'idle') {
|
||||
supplierAutomat.actions.load().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { suppliers, status } = this.state;
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (status === 'loading') {
|
||||
return <select disabled><option>Lieferanten laden...</option></select>;
|
||||
}
|
||||
|
||||
return (
|
||||
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">-- Lieferant wählen --</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Creating a Supplier
|
||||
|
||||
```js
|
||||
import supplierAutomat from '../automats/supplierAutomat';
|
||||
|
||||
async function addNewSupplier(name) {
|
||||
try {
|
||||
const supplier = await supplierAutomat.actions.create({ name });
|
||||
console.log('Supplier created:', supplier);
|
||||
} catch (err) {
|
||||
console.error('Failed to create supplier:', err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices & Gotchas
|
||||
|
||||
1. **Automatic Refresh**: `create`, `update`, and `remove` automatically call `reload()`, so subscribers will receive the updated list without manual re-querying.
|
||||
2. **Inflight Safety**: You can safely call `supplierAutomat.actions.load()` in multiple components mounted at the same time without causing duplicate HTTP requests.
|
||||
173
docs/unitAutomat.md
Normal file
173
docs/unitAutomat.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# `unitAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/unitAutomat.js`](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)
|
||||
> **Registration Key**: `'unit'` (`Automat.get('unit')` / `window.__AUTOMATS__.get('unit')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`unitAutomat` manages the active company's allowed units of measure (e.g. Stück, kg, Meter, Liter). Units are attached to articles and delivery lineitems to ensure consistent units of measure and price calculation across the catalog and invoices.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Company Scoping**: Caches all measurement units for the currently selected company.
|
||||
- **Alphabetical Sorting**: Automatically sorts units by their display symbol or SI code using German collation.
|
||||
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` transitions to `'authed'` with a selected company, and resets when the session ends.
|
||||
- **Full Unit Maintenance**: Upserting (creating or modifying) and deleting units by SI key.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
interface Unit {
|
||||
si: string; // International standard code (e.g. 'C62', 'KGM', 'LTR')
|
||||
symbol?: string; // Display symbol (e.g. 'Stk', 'kg', 'l')
|
||||
name?: string; // Full name (e.g. 'Stück', 'Kilogramm')
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface UnitState {
|
||||
/** Alphabetically sorted list of measurement units. */
|
||||
units: Array<Unit>;
|
||||
/** Async lifecycle status. */
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Last error message if load failed. */
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
units: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, { name: 'unit' });
|
||||
```
|
||||
|
||||
- **`name`**: `'unit'`
|
||||
- **`persist`**: `false`
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Access actions via `unitAutomat.actions.<method>()`.
|
||||
|
||||
### `load(): Promise<Array<Unit>>`
|
||||
Fetches all units for the active company via `GET /api/unit`. Sorts them, updates state, and sets status to `'succeeded'`.
|
||||
|
||||
### `upsert(body: object): Promise<Array<Unit>>`
|
||||
Creates or updates a unit via `POST /api/unit/upsert`. Merges the updated unit into `state.units` by matching on its `si` property, re-sorts the list, updates state, and returns the full updated list.
|
||||
|
||||
### `delete(si: string): Promise<void>`
|
||||
Deletes a unit by SI code via `DELETE /api/unit/:si` and filters it out of `state.units`.
|
||||
|
||||
### `reset(): void`
|
||||
Resets the state back to `initialState`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Automat Subscriptions
|
||||
|
||||
`unitAutomat` tracks authentication changes automatically:
|
||||
|
||||
```js
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void unitAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
unitAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Unit Select Dropdown
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import unitAutomat from '../automats/unitAutomat';
|
||||
|
||||
export class UnitSelect extends PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
units: unitAutomat.state.units,
|
||||
status: unitAutomat.state.status,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = unitAutomat.subscribe(this, ['units', 'status']);
|
||||
if (this.state.status === 'idle') {
|
||||
unitAutomat.actions.load().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { units, status } = this.state;
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (status === 'loading') {
|
||||
return <select disabled><option>Einheiten laden...</option></select>;
|
||||
}
|
||||
|
||||
return (
|
||||
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">-- Einheit wählen --</option>
|
||||
{units.map((u) => (
|
||||
<option key={u.si} value={u.symbol || u.si}>
|
||||
{u.symbol || u.si} {u.name ? `(${u.name})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B. Upserting a Unit
|
||||
|
||||
```js
|
||||
import unitAutomat from '../automats/unitAutomat';
|
||||
|
||||
async function addCustomUnit(si, symbol, name) {
|
||||
try {
|
||||
await unitAutomat.actions.upsert({ si, symbol, name });
|
||||
console.log('Unit saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save unit:', err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices & Gotchas
|
||||
|
||||
1. **Unique Key**: Units are uniquely identified by their `si` property (not an integer ID).
|
||||
2. **Sort Guarantee**: Units in `state.units` are sorted alphabetically based on `u.symbol ?? u.si`.
|
||||
116
docs/userSettingsAutomat.md
Normal file
116
docs/userSettingsAutomat.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# `userSettingsAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/userSettingsAutomat.js`](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js)
|
||||
> **Registration Key**: `'userSettings'` (`Automat.get('userSettings')` / `window.__AUTOMATS__.get('userSettings')`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`userSettingsAutomat` manages client-side user interface preferences and layout options. Unlike most other automats in Dashfront that synchronize with the backend, `userSettingsAutomat` is persisted locally using IndexedDB (`persist: true`).
|
||||
|
||||
### Core Responsibilities
|
||||
- **Local Persistence**: Preferences are saved across browser reloads and restarts without requiring server calls or database migrations.
|
||||
- **Docking Preferences**: Remembers the user's preferred layout docking position for attachment viewers (such as the PDF receipt preview and text editor) relative to the article list.
|
||||
- **Zero Latency**: State is instantly available in local storage/IndexedDB on client startup.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Structure
|
||||
|
||||
```ts
|
||||
type ViewerPosition = 'top' | 'right' | 'below';
|
||||
|
||||
interface UserSettingsState {
|
||||
/**
|
||||
* Preferred docking position of the attachment viewer
|
||||
* (text editor / PDF preview) relative to the article list.
|
||||
*/
|
||||
viewerPosition: ViewerPosition;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial State
|
||||
```js
|
||||
const initialState = {
|
||||
viewerPosition: 'top',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Options
|
||||
|
||||
```js
|
||||
new Automat(initialState, actions, {
|
||||
name: 'userSettings',
|
||||
persist: true,
|
||||
});
|
||||
```
|
||||
|
||||
- **`name`**: `'userSettings'`
|
||||
- **`persist`**: `true` — `react-automat` automatically reads and writes this state to IndexedDB.
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & Methods
|
||||
|
||||
Actions are available on `userSettingsAutomat.actions` and exported as standalone functions:
|
||||
|
||||
### `setViewerPosition(position: 'top' | 'right' | 'below'): void`
|
||||
Validates that `position` is one of `'top'`, `'right'`, or `'below'`. If valid, updates `state.viewerPosition` and automatically commits the change to IndexedDB.
|
||||
|
||||
---
|
||||
|
||||
## 5. Usage Examples
|
||||
|
||||
### A. React `PureComponent` Layout Container
|
||||
|
||||
```jsx
|
||||
import { PureComponent } from 'react';
|
||||
import userSettingsAutomat, { setViewerPosition } from '../automats/userSettingsAutomat';
|
||||
|
||||
export class DeliveryEditorLayout extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewerPosition: userSettingsAutomat.state.viewerPosition,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = userSettingsAutomat.subscribe(this, 'viewerPosition');
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { viewerPosition } = this.state;
|
||||
const isHorizontal = viewerPosition === 'right';
|
||||
|
||||
return (
|
||||
<div className={`editor-container ${viewerPosition}`}>
|
||||
<div className="toolbar">
|
||||
<span>Viewer Position:</span>
|
||||
<button onClick={() => setViewerPosition('top')}>Oben</button>
|
||||
<button onClick={() => setViewerPosition('right')}>Rechts</button>
|
||||
<button onClick={() => setViewerPosition('below')}>Unten</button>
|
||||
</div>
|
||||
<div className={`layout-body ${isHorizontal ? 'row' : 'column'}`}>
|
||||
<div className="viewer-panel">PDF / Beleg-Vorschau</div>
|
||||
<div className="table-panel">Positionen-Tabelle</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Best Practices & Gotchas
|
||||
|
||||
1. **Validation**: Invalid values passed to `setViewerPosition` are ignored and will not update state.
|
||||
2. **Persistence Reset**: To clear persisted settings programmatically, `react-automat` provides `userSettingsAutomat.clearPersistence()`.
|
||||
118
eslint.config.js
Normal file
118
eslint.config.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
const banMuiTooltipRule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ban MUI Tooltip component',
|
||||
},
|
||||
messages: {
|
||||
badDesign: '<Tooltip> is bad design',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const muiTooltipIdentifiers = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === '@mui/material/Tooltip') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.local?.name) {
|
||||
muiTooltipIdentifiers.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
} else if (node.source.value === '@mui/material') {
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported?.name === 'Tooltip'
|
||||
) {
|
||||
context.report({
|
||||
node: specifier,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
if (specifier.local?.name) {
|
||||
muiTooltipIdentifiers.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
let tagName = '';
|
||||
if (node.name.type === 'JSXIdentifier') {
|
||||
tagName = node.name.name;
|
||||
} else if (node.name.type === 'JSXMemberExpression') {
|
||||
tagName = node.name.property.name;
|
||||
}
|
||||
|
||||
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'dist-ssr/**',
|
||||
'node_modules/**',
|
||||
'certs/**',
|
||||
'tmp/**',
|
||||
'.dashfront-token',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx,mjs}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
React: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
react: {
|
||||
rules: {
|
||||
'no-mui-tooltip': banMuiTooltipRule,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/no-mui-tooltip': 'error',
|
||||
'no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^(React|_)',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||
'no-debugger': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
884
generate-automat-graph.js
Normal file
884
generate-automat-graph.js
Normal file
@@ -0,0 +1,884 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outputPath = join(__dirname, 'automat-graph.svg');
|
||||
const dotOutputPath = join(__dirname, 'automat-graph.dot');
|
||||
|
||||
const COLORS = {
|
||||
background: '#1e2430',
|
||||
tableBorder: '#3f4c5f',
|
||||
headerBg: '#33415c',
|
||||
headerText: '#e2e8f0',
|
||||
rowBg: '#2a3242',
|
||||
rowBgAlt: '#272e3d',
|
||||
keyBg: '#2b3a52',
|
||||
text: '#d8dee9',
|
||||
muted: '#8b95a7',
|
||||
clusterFill: '#202a3a',
|
||||
};
|
||||
|
||||
const BADGE_COLORS = {
|
||||
STATE: { fg: '#c084fc', bg: '#2b1a42' }, // Purple
|
||||
ACTION: { fg: '#4ade80', bg: '#143826' }, // Green
|
||||
SELECT: { fg: '#38bdf8', bg: '#182b40' }, // Sky Blue
|
||||
API: { fg: '#fbbf24', bg: '#3b2c12' }, // Amber
|
||||
SSE: { fg: '#f87171', bg: '#3a181e' }, // Rose/Red
|
||||
HOOK: { fg: '#fb923c', bg: '#382414' }, // Orange/Gold
|
||||
};
|
||||
|
||||
const EDGE_COLORS = [
|
||||
'#5aa7ff', // Blue
|
||||
'#6bdf9a', // Mint Green
|
||||
'#f5c15c', // Golden Amber
|
||||
'#ff7fc2', // Pink
|
||||
'#c79bff', // Lavender
|
||||
'#5cd6d6', // Cyan
|
||||
'#ff9f5a', // Coral
|
||||
'#8f95ff', // Indigo
|
||||
'#ff6b6b', // Soft Red
|
||||
'#58d18a', // Emerald
|
||||
'#4fc4e8', // Sky
|
||||
'#a98cff', // Violet
|
||||
'#ff8f66', // Tangerine
|
||||
'#4ecb8f', // Seafoam
|
||||
'#ff7aa8', // Rose
|
||||
'#e0b25a', // Sand
|
||||
'#56c8c8', // Aqua
|
||||
'#ff5f9e', // Fuchsia
|
||||
'#ffb25a', // Ochre
|
||||
'#66b3ff', // Periwinkle
|
||||
];
|
||||
|
||||
const CLUSTERS = [
|
||||
{
|
||||
id: 'session_realtime',
|
||||
label: 'Session & Real-Time Hub',
|
||||
automats: ['authAutomat', 'sseAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'company_basedata',
|
||||
label: 'Company & Consolidated Base Data',
|
||||
automats: ['companyAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'catalog_products',
|
||||
label: 'Catalog & Products',
|
||||
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'search_indexing',
|
||||
label: 'Search & Indexing',
|
||||
automats: ['articleIndexAutomat', 'articleSearchAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'purchasing_logistics',
|
||||
label: 'Deliveries & Inbound',
|
||||
automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'sales_crm',
|
||||
label: 'Sales & CRM',
|
||||
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'ai_workspace',
|
||||
label: 'AI Assistant & Workspace',
|
||||
automats: ['chatAutomat', 'vfsAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'audit_grids',
|
||||
label: 'Audit & Virtual Grids',
|
||||
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat', 'ledgerHistoryGridAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'system_ui',
|
||||
label: 'UI, Navigation & System',
|
||||
automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
|
||||
},
|
||||
];
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function truncate(str, max = 30) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete metadata for every Automat in Dashfront.
|
||||
*/
|
||||
const AUTOMAT_SPECS = [
|
||||
// 1. Session & Real-Time Hub
|
||||
{
|
||||
id: 'authAutomat',
|
||||
title: 'authAutomat',
|
||||
subtitle: 'Always-On Session & Multi-Tenant Gateway',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
||||
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
||||
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
||||
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
|
||||
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
||||
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
||||
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
||||
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
||||
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
||||
{ type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
|
||||
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sseAutomat',
|
||||
title: 'sseAutomat',
|
||||
subtitle: 'Single Persistent SSE Connection',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'status', desc: '\'connected\' | \'error\'' },
|
||||
{ type: 'STATE', name: 'clientId (cid)', desc: 'Tab-scoped UUID in session' },
|
||||
{ type: 'ACTION', name: 'reconnect()', desc: 'Cycle on company switch' },
|
||||
{ type: 'ACTION', name: 'on(event, handler)', desc: 'Register signal listener' },
|
||||
{ type: 'ACTION', name: 'watchArticle(sku)', desc: 'Attach article-<sku>' },
|
||||
{ type: 'SELECT', name: 'invalidateArticle(sku)', desc: 'Silent cache refresh' },
|
||||
{ type: 'SSE', name: 'category / articles', desc: 'Catalog mutation events', port: 'p_sse_events' },
|
||||
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment events' },
|
||||
{ type: 'API', name: 'GET /api/events', desc: 'Persistent stream' },
|
||||
],
|
||||
},
|
||||
|
||||
// 2. Company & Consolidated Base Data
|
||||
{
|
||||
id: 'companyAutomat',
|
||||
title: 'companyAutomat',
|
||||
subtitle: 'Consolidated Company & Base Data Hub',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' },
|
||||
{ type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' },
|
||||
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' },
|
||||
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' },
|
||||
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' },
|
||||
{ type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' },
|
||||
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' },
|
||||
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' },
|
||||
{ type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' },
|
||||
{ type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' },
|
||||
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' },
|
||||
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' },
|
||||
{ type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' },
|
||||
{ type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' },
|
||||
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
|
||||
{ type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' },
|
||||
],
|
||||
},
|
||||
|
||||
// 3. Catalog & Products
|
||||
{
|
||||
id: 'categoryAutomat',
|
||||
title: 'categoryAutomat',
|
||||
subtitle: 'Hierarchical Category Tree',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'categories: Map', desc: 'ID -> Category Node Map', port: 'p_cat_categories' },
|
||||
{ type: 'STATE', name: 'rootCategoryId', desc: 'Company root node ID' },
|
||||
{ type: 'STATE', name: 'articleCount', desc: 'Total parent items' },
|
||||
{ type: 'ACTION', name: 'loadTree()', desc: 'Fetch tree & warm views', port: 'p_cat_loadTree' },
|
||||
{ type: 'ACTION', name: 'createCategory()', desc: 'Insert child node' },
|
||||
{ type: 'ACTION', name: 'moveCategory()', desc: 'Parent reparenting' },
|
||||
{ type: 'ACTION', name: 'deleteCategory()', desc: 'Prune subtree' },
|
||||
{ type: 'SELECT', name: 'getCategoryBreadcrumbs()', desc: 'Hierarchical path trace' },
|
||||
{ type: 'API', name: 'GET /api/category', desc: 'Recursive tree load' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'categoryViewAutomat',
|
||||
title: 'categoryViewAutomat',
|
||||
subtitle: 'KeyedAutomat (Slug Path)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'views: Map<slug, Data>', desc: 'Articles & direct children' },
|
||||
{ type: 'ACTION', name: 'loadAllCategoryViews()', desc: 'Prewarm all tree slugs' },
|
||||
{ type: 'ACTION', name: 'reloadLoadedCategoryViews()', desc: 'Refresh cached views' },
|
||||
{ type: 'ACTION', name: 'invalidateAllCategoryViews()', desc: 'Clear on SSE signal' },
|
||||
{ type: 'SELECT', name: 'collectLoadedCategoryViewData()', desc: 'Extract union data', port: 'p_catview_collect' },
|
||||
{ type: 'SELECT', name: 'getOrCreateCategoryAutomat()', desc: 'Per-slug reactive store' },
|
||||
{ type: 'API', name: 'GET /api/category/:id/articles', desc: 'Category article batch' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'artikelAutomat',
|
||||
title: 'artikelAutomat',
|
||||
subtitle: 'In-Memory Article Record Cache',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'articles: Map<sku, Object>', desc: 'Deduplicated SKU cache', port: 'p_art_set' },
|
||||
{ type: 'ACTION', name: 'loadArticle(sku)', desc: 'Fetch or cached promise' },
|
||||
{ type: 'ACTION', name: 'reloadArticle(sku)', desc: 'Silent background sync' },
|
||||
{ type: 'ACTION', name: 'setArticleName(sku, name)', desc: 'Optimistic rename', port: 'p_art_setName' },
|
||||
{ type: 'ACTION', name: 'clearArticle(sku)', desc: 'Evict SKU from cache' },
|
||||
{ type: 'SELECT', name: 'getArticle(sku)', desc: 'Sync constructor read' },
|
||||
{ type: 'SELECT', name: 'createArticleSelector(sku)', desc: 'Fine-grained selector' },
|
||||
{ type: 'API', name: 'GET /api/article/:sku', desc: 'Full article document' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'articleDetailAutomat',
|
||||
title: 'articleDetailAutomat',
|
||||
subtitle: 'KeyedAutomat (SKU)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'articleHistory: Map<sku>', desc: 'Change history log', port: 'p_artdet_hist' },
|
||||
{ type: 'STATE', name: 'articleSupplier: Map<sku>', desc: 'Assigned supplier list' },
|
||||
{ type: 'ACTION', name: 'articleHistoryAutomat.load()', desc: 'Fetch SKU audit trail' },
|
||||
{ type: 'ACTION', name: 'articleSupplierAutomat.load()', desc: 'Fetch supplier catalog' },
|
||||
{ type: 'API', name: 'GET /api/article/:sku/history', desc: 'Audit change entries' },
|
||||
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
|
||||
],
|
||||
},
|
||||
|
||||
// 4. Search & Discovery
|
||||
{
|
||||
id: 'articleIndexAutomat',
|
||||
title: 'articleIndexAutomat',
|
||||
subtitle: 'Derived Client Search Index',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: ArticleIndexRecord[]', desc: 'De-duplicated article union', port: 'p_idx_items' },
|
||||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Index lifecycle flag' },
|
||||
{ type: 'ACTION', name: 'buildArticleIndex()', desc: 'Derive from category views' },
|
||||
{ type: 'ACTION', name: 'setArticleIndexName()', desc: 'Patch on rename' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Re-derive from views' },
|
||||
{ type: 'SELECT', name: 'selectArticleIndexItems()', desc: 'Fast record projection' },
|
||||
{ type: 'SELECT', name: 'ensureLoaded()', desc: 'Await warm views' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'articleSearchAutomat',
|
||||
title: 'articleSearchAutomat',
|
||||
subtitle: 'In-Memory FlexSearch Engine',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'target: string', desc: 'Active search query' },
|
||||
{ type: 'STATE', name: 'filter: string', desc: 'Active category filter' },
|
||||
{ type: 'STATE', name: 'result: ArticleSearchResult', desc: 'Facets, recordset, count' },
|
||||
{ type: 'ACTION', name: 'search(target, filter)', desc: 'Synchronous indexed query' },
|
||||
{ type: 'SELECT', name: 'selectSearchResult()', desc: 'Reactive search outcome' },
|
||||
],
|
||||
},
|
||||
|
||||
// 5. Deliveries & Inbound
|
||||
{
|
||||
id: 'deliveryAutomat',
|
||||
title: 'deliveryAutomat',
|
||||
subtitle: 'Chunk-Paginated Deliveries',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
|
||||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch & sync' },
|
||||
{ type: 'ACTION', name: 'reset()', desc: 'Clear on session switch' },
|
||||
{ type: 'API', name: 'GET /api/delivery', desc: 'Inbound delivery log' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deliveryEditorAutomat',
|
||||
title: 'deliveryEditorAutomat',
|
||||
subtitle: 'Inbound Receipt & OCR Machine',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'lines: DeliveryLine[]', desc: 'Uncommitted draft lines', port: 'p_deled_lines' },
|
||||
{ type: 'STATE', name: 'deliveryEntity', desc: 'Selected supplier ID', port: 'p_deled_supplier' },
|
||||
{ type: 'STATE', name: 'attachments: File[]', desc: 'Invoice PDFs & OCR results' },
|
||||
{ type: 'STATE', name: 'draftCount: number', desc: 'Uncommitted lines for badge' },
|
||||
{ type: 'ACTION', name: 'addLine() / updateLine()', desc: 'Interactive item editing' },
|
||||
{ type: 'ACTION', name: 'commitDelivery()', desc: 'Persist goods receipt', port: 'p_deled_commit' },
|
||||
{ type: 'SELECT', name: 'buildSupplierArticlesMap()', desc: 'Auto-match SKU/EAN' },
|
||||
{ type: 'API', name: 'POST /api/delivery', desc: 'Create goods receipt' },
|
||||
],
|
||||
},
|
||||
|
||||
// 6. Sales & CRM
|
||||
{
|
||||
id: 'quoteAutomat',
|
||||
title: 'quoteAutomat',
|
||||
subtitle: 'Chunk-Paginated Quotes',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' },
|
||||
{ type: 'ACTION', name: 'reset()', desc: 'Reset on logout' },
|
||||
{ type: 'API', name: 'GET /api/quote', desc: 'Company quote list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'orderAutomat',
|
||||
title: 'orderAutomat',
|
||||
subtitle: 'Chunk-Paginated Order Engine',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Order[]', desc: 'Sales orders & lines', port: 'p_order_items' },
|
||||
{ type: 'STATE', name: 'total: number', desc: 'Unfiltered order count' },
|
||||
{ type: 'STATE', name: 'onlyUnpaid / onlyUndelivered', desc: 'Fulfillment filters' },
|
||||
{ type: 'ACTION', name: 'loadChunk(index)', desc: 'Chunk size 50 prefetch' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Silent orders re-fetch' },
|
||||
{ type: 'SELECT', name: 'selectOrders() / selectTotal()', desc: 'Filter-applied slice' },
|
||||
{ type: 'API', name: 'GET /api/order', desc: 'Paged order stream' },
|
||||
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment mutations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entityAutomat',
|
||||
title: 'entityAutomat',
|
||||
subtitle: 'Customer & Partner Directory',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
|
||||
{ type: 'ACTION', name: 'reset()', desc: 'Reset on session switch' },
|
||||
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
|
||||
],
|
||||
},
|
||||
|
||||
// 7. AI Assistant & Workspace
|
||||
{
|
||||
id: 'chatAutomat',
|
||||
title: 'chatAutomat',
|
||||
subtitle: 'AI Assistant Drawer & SSE',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'isOpen: boolean', desc: 'Docked chat drawer toggle' },
|
||||
{ type: 'STATE', name: 'conversations: []', desc: 'Conversation sessions' },
|
||||
{ type: 'STATE', name: 'messages: ModelMessage[]', desc: 'Live message transcript', port: 'p_chat_msg' },
|
||||
{ type: 'STATE', name: 'sseStatus: \'connected\'', desc: 'Token streaming stream', port: 'p_chat_sse' },
|
||||
{ type: 'ACTION', name: 'sendMessage(text)', desc: 'Prompt model with tools' },
|
||||
{ type: 'ACTION', name: 'answerAskUser()', desc: 'Interactive tool modal' },
|
||||
{ type: 'SELECT', name: 'userEnv / routeTools', desc: 'Client navigation tool', port: 'p_chat_tools' },
|
||||
{ type: 'API', name: 'POST /api/modelconversation', desc: 'AI conversation session' },
|
||||
{ type: 'SSE', name: 'modelconversation/events', desc: 'Live token stream' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vfsAutomat',
|
||||
title: 'vfsAutomat',
|
||||
subtitle: 'Virtual Filesystem Explorer',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'files: VfsFile[]', desc: 'Company VFS workspace' },
|
||||
{ type: 'STATE', name: 'selectedFileName', desc: 'Current active file' },
|
||||
{ type: 'STATE', name: 'fileContent / isDirty', desc: 'Editor buffer & diff' },
|
||||
{ type: 'ACTION', name: 'selectFile() / saveFile()', desc: 'Applet editor buffer' },
|
||||
{ type: 'ACTION', name: 'uploadFiles() / deleteFile()', desc: 'File manipulations' },
|
||||
{ type: 'API', name: 'GET /api/vfs', desc: 'VFS directory index' },
|
||||
{ type: 'SSE', name: 'vfs', desc: 'VFS mutation signal' },
|
||||
],
|
||||
},
|
||||
|
||||
// 8. Audit & Virtual Grids
|
||||
{
|
||||
id: 'articleHistoryGridAutomat',
|
||||
title: 'articleHistoryGridAutomat',
|
||||
subtitle: 'createGridAutomat (Article)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||
{ type: 'API', name: 'GET /api/article/history', desc: 'Infinite audit window' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'globalHistoryGridAutomat',
|
||||
title: 'globalHistoryGridAutomat',
|
||||
subtitle: 'createGridAutomat (Global)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||
{ type: 'API', name: 'GET /api/globalhistory', desc: 'Company global audit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ledgerHistoryGridAutomat',
|
||||
title: 'ledgerHistoryGridAutomat',
|
||||
subtitle: 'createGridAutomat (Ledger)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||
{ type: 'API', name: 'GET /api/ledger/history', desc: 'Append-only ledger audit' },
|
||||
],
|
||||
},
|
||||
|
||||
// 9. UI, Navigation & System
|
||||
{
|
||||
id: 'navigationAutomat',
|
||||
title: 'navigationAutomat',
|
||||
subtitle: 'Cross-Section Navigation Memory',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'lastDeliveryPath', desc: 'Active receipt subpath', port: 'p_nav_delpath' },
|
||||
{ type: 'STATE', name: 'lastArticleSku', desc: 'Active SKU selection', port: 'p_nav_sku' },
|
||||
{ type: 'STATE', name: 'lastCategorySlug / Path', desc: 'Active category tree node', port: 'p_nav_catslug' },
|
||||
{ type: 'STATE', name: 'lastSearchTerm / Path', desc: 'Preserved search query' },
|
||||
{ type: 'ACTION', name: 'setLastDeliveryPath()', desc: 'Restore on sidebar click' },
|
||||
{ type: 'ACTION', name: 'setLastArticleSku()', desc: 'Restore SKU on section click' },
|
||||
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'helpAutomat',
|
||||
title: 'helpAutomat',
|
||||
subtitle: 'IndexedDB Contextual Help',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'enabled: boolean', desc: 'Interactive help badges' },
|
||||
{ type: 'STATE', name: 'activeHelpId: string|null', desc: 'Active popper ID', port: 'p_help_active' },
|
||||
{ type: 'ACTION', name: 'toggle() / setEnabled()', desc: 'Toggle badge mode' },
|
||||
{ type: 'ACTION', name: 'openPopper() / closePopper()', desc: 'Single active popper' },
|
||||
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pwaAutomat',
|
||||
title: 'pwaAutomat',
|
||||
subtitle: 'Service Worker & Offline Lifecycle',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'needRefresh: boolean', desc: 'New SW release pending' },
|
||||
{ type: 'STATE', name: 'offlineReady: boolean', desc: 'Offline assets cached' },
|
||||
{ type: 'STATE', name: 'dialogOpen / updating', desc: 'Prompt modal state' },
|
||||
{ type: 'ACTION', name: 'applyUpdate()', desc: 'Skip waiting & reload' },
|
||||
{ type: 'ACTION', name: 'openDialog() / closeDialog()', desc: 'Modal controls' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* High-fidelity relationships between Automats:
|
||||
* - Lifecycle synchronization (Auth / Session)
|
||||
* - Real-time SSE dispatch & invalidation
|
||||
* - Hierarchical catalog derivation & indexing
|
||||
* - Purchasing & inventory workflows
|
||||
* - AI tools & virtual workspace
|
||||
*/
|
||||
const EDGE_DEFS = [
|
||||
// Session & Auth lifecycle
|
||||
{ from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' },
|
||||
{ from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' },
|
||||
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' },
|
||||
{ from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'globalHistoryGridAutomat', label: 'reload audit on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'ledgerHistoryGridAutomat', label: 'reload ledger on authed', port: 'p_auth_status' },
|
||||
|
||||
// Real-time SSE dispatch
|
||||
{ from: 'sseAutomat', to: 'categoryAutomat', label: '\'category\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'categoryViewAutomat', label: 'invalidate views on sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' },
|
||||
|
||||
// Catalog, Indexing & Search pipeline
|
||||
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' },
|
||||
{ from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' },
|
||||
{ from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' },
|
||||
{ from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' },
|
||||
{ from: 'categoryAutomat', to: 'articleSearchAutomat', label: 'categoryFacets', port: 'p_cat_categories' },
|
||||
{ from: 'artikelAutomat', to: 'articleIndexAutomat', label: 'setArticleIndexName (optimistic)', port: 'p_art_setName' },
|
||||
{ from: 'artikelAutomat', to: 'categoryAutomat', label: 'articleCount updates', port: 'p_art_set' },
|
||||
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
|
||||
|
||||
// Purchasing & Deliveries
|
||||
{ from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
|
||||
{ from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' },
|
||||
{ from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' },
|
||||
|
||||
// Sales & CRM
|
||||
{ from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' },
|
||||
{ from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' },
|
||||
|
||||
// AI Assistant & Workspace
|
||||
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
|
||||
{ from: 'chatAutomat', to: 'navigationAutomat', label: 'set_user_route tool', port: 'p_chat_tools' },
|
||||
{ from: 'chatAutomat', to: 'sseAutomat', label: 'getSseClientId()', port: 'p_chat_sse' },
|
||||
|
||||
// UI Navigation & Preferences
|
||||
{ from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' },
|
||||
{ from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' },
|
||||
{ from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' },
|
||||
{ from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
|
||||
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
|
||||
|
||||
// Self-loops
|
||||
{ from: 'categoryAutomat', to: 'categoryAutomat', label: 'nested tree hierarchy', port: 'p_cat_categories' },
|
||||
{ from: 'artikelAutomat', to: 'artikelAutomat', label: 'variants & parent SKU', port: 'p_art_set' },
|
||||
{ from: 'chatAutomat', to: 'chatAutomat', label: 'stream delta appends', port: 'p_chat_msg' },
|
||||
];
|
||||
|
||||
function edgeKey(edge) {
|
||||
return `${edge.from}|${edge.to}|${edge.label}`;
|
||||
}
|
||||
|
||||
function assignEdgeColors(edges) {
|
||||
const colors = new Map();
|
||||
let next = 0;
|
||||
for (const edge of edges) {
|
||||
const key = edgeKey(edge);
|
||||
if (!colors.has(key)) {
|
||||
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
|
||||
next++;
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spanning ranking forest: only one primary outgoing edge per automat
|
||||
* is used for rank constraints. Other edges get constraint=false to avoid
|
||||
* vertical over-stretching.
|
||||
*/
|
||||
function selectRankingEdgeKeys(edges) {
|
||||
const ranking = new Set();
|
||||
const bySource = new Map();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.from === edge.to) continue;
|
||||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||
bySource.get(edge.from).push(edge);
|
||||
}
|
||||
|
||||
for (const group of bySource.values()) {
|
||||
group.sort((a, b) => a.label.localeCompare(b.label));
|
||||
ranking.add(edgeKey(group[0]));
|
||||
}
|
||||
|
||||
return ranking;
|
||||
}
|
||||
|
||||
function buildLegendLabel() {
|
||||
const badgeRow = (type, desc) => {
|
||||
const c = BADGE_COLORS[type];
|
||||
return (
|
||||
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="48">` +
|
||||
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${type}</B></FONT></TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
|
||||
);
|
||||
};
|
||||
const row = (swatch, text) =>
|
||||
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
|
||||
|
||||
return (
|
||||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
|
||||
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
|
||||
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
|
||||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>Automat Graph Legend</B></FONT></TD></TR>` +
|
||||
badgeRow('STATE', 'Reactive store state property') +
|
||||
badgeRow('ACTION', 'State mutation action') +
|
||||
badgeRow('SELECT', 'Reactive PureComponent selector') +
|
||||
badgeRow('API', 'Backend REST/Fetch endpoint') +
|
||||
badgeRow('SSE', 'Inbound Server-Sent Event trigger') +
|
||||
badgeRow('HOOK', 'Auth / persistence lifecycle hook') +
|
||||
row(
|
||||
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||||
'Inbound arrival port on Automat header'
|
||||
) +
|
||||
row(
|
||||
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||||
'Reactive dataflow / upstream trigger'
|
||||
) +
|
||||
`</TABLE>>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderAutomatNode(spec, inboundCount = 0) {
|
||||
if (spec.id === 'Legend') {
|
||||
return buildLegendLabel();
|
||||
}
|
||||
|
||||
const titleFont =
|
||||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(spec.title)}</B></FONT>` +
|
||||
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(spec.subtitle)}</FONT>`;
|
||||
|
||||
let headerInner;
|
||||
if (inboundCount > 0) {
|
||||
const portCells = [];
|
||||
for (let i = 0; i < inboundCount; i++) {
|
||||
portCells.push(
|
||||
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
|
||||
);
|
||||
}
|
||||
headerInner =
|
||||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||
`<TR>${portCells.join('')}</TR>` +
|
||||
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
|
||||
`</TABLE>`;
|
||||
} else {
|
||||
headerInner = titleFont;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
|
||||
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
|
||||
];
|
||||
|
||||
for (const [i, entry] of spec.entries.entries()) {
|
||||
const bColor = BADGE_COLORS[entry.type] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||||
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||||
const portAttr = entry.port ? ` PORT="${escapeHtml(entry.port)}"` : '';
|
||||
|
||||
rows.push(
|
||||
`<TR><TD${portAttr} COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||
`<TR>` +
|
||||
`<TD BGCOLOR="${bColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="48">` +
|
||||
`<FONT COLOR="${bColor.fg}" POINT-SIZE="8"><B>${escapeHtml(entry.type)}</B></FONT></TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||||
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(entry.name)}</B></FONT></TD>` +
|
||||
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||||
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(truncate(entry.desc, 30))}</FONT></TD>` +
|
||||
`</TR></TABLE></TD></TR>`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
|
||||
rows.join('') +
|
||||
`</TABLE>>`
|
||||
);
|
||||
}
|
||||
|
||||
function assignEdgeAnchors(edges, positions) {
|
||||
const headPort = new Map();
|
||||
const tailCompass = new Map();
|
||||
const inboundCounts = new Map();
|
||||
|
||||
const byTarget = new Map();
|
||||
const bySource = new Map();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
|
||||
byTarget.get(edge.to).push(edge);
|
||||
|
||||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||
bySource.get(edge.from).push(edge);
|
||||
}
|
||||
|
||||
for (const [target, group] of byTarget) {
|
||||
group.sort((a, b) => {
|
||||
const ax = positions?.get(a.from)?.x ?? 0;
|
||||
const bx = positions?.get(b.from)?.x ?? 0;
|
||||
if (ax !== bx) return ax - bx;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
inboundCounts.set(target, group.length);
|
||||
group.forEach((edge, i) => {
|
||||
headPort.set(edgeKey(edge), `in${i}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of bySource.values()) {
|
||||
group.forEach((edge, i) => {
|
||||
if (edge.from === edge.to) {
|
||||
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
|
||||
return;
|
||||
}
|
||||
const from = positions?.get(edge.from);
|
||||
const to = positions?.get(edge.to);
|
||||
const fromX = from?.x ?? 0;
|
||||
const toX = to?.x ?? fromX;
|
||||
const goEast = toX >= fromX;
|
||||
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
|
||||
});
|
||||
}
|
||||
|
||||
return { headPort, tailCompass, inboundCounts };
|
||||
}
|
||||
|
||||
function buildDot(specsMap, edges, positions, anchors) {
|
||||
const edgeColors = assignEdgeColors(edges);
|
||||
const rankingKeys = selectRankingEdgeKeys(edges);
|
||||
const { headPort, tailCompass, inboundCounts } =
|
||||
anchors ?? assignEdgeAnchors(edges, positions);
|
||||
|
||||
const lines = [
|
||||
'digraph automats {',
|
||||
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
|
||||
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||||
' compound=true, mclimit=1.0];',
|
||||
' node [shape=plaintext, fontname="Helvetica"];',
|
||||
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
|
||||
'',
|
||||
];
|
||||
|
||||
const clusteredAutomats = new Set();
|
||||
|
||||
for (const cluster of CLUSTERS) {
|
||||
const presentInCluster = cluster.automats.filter((id) => specsMap.has(id));
|
||||
if (presentInCluster.length === 0) continue;
|
||||
|
||||
lines.push(` subgraph cluster_${cluster.id} {`);
|
||||
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
|
||||
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
|
||||
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
|
||||
|
||||
for (const autoId of presentInCluster) {
|
||||
clusteredAutomats.add(autoId);
|
||||
const spec = specsMap.get(autoId);
|
||||
const inCount = inboundCounts.get(autoId) ?? 0;
|
||||
const label = renderAutomatNode(spec, inCount);
|
||||
lines.push(` ${autoId} [label=${label}];`);
|
||||
}
|
||||
lines.push(' }');
|
||||
}
|
||||
|
||||
// Any unclustered automats
|
||||
for (const [autoId, spec] of specsMap) {
|
||||
if (clusteredAutomats.has(autoId)) continue;
|
||||
const inCount = inboundCounts.get(autoId) ?? 0;
|
||||
const label = renderAutomatNode(spec, inCount);
|
||||
lines.push(` ${autoId} [label=${label}];`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = edgeKey(edge);
|
||||
const color = edgeColors.get(key) ?? '#5aa7ff';
|
||||
const isRanking = rankingKeys.has(key);
|
||||
const hp = headPort.get(key) ?? 'in0';
|
||||
const tc = tailCompass.get(key) ?? 'e';
|
||||
|
||||
const attrs = [
|
||||
`color="${color}"`,
|
||||
`fontcolor="${color}"`,
|
||||
`tooltip="${escapeHtml(`${edge.from} → ${edge.to} (${edge.label})`)}"`,
|
||||
`label=" ${escapeHtml(edge.label)} "`,
|
||||
];
|
||||
|
||||
if (!isRanking && edge.from !== edge.to) {
|
||||
attrs.push('constraint=false', 'weight=1');
|
||||
} else {
|
||||
attrs.push('weight=3');
|
||||
}
|
||||
|
||||
// Source port from row entry if available, else exit compass
|
||||
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
|
||||
const toPort = `:${hp}:n`;
|
||||
|
||||
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function parsePlainPositions(plain) {
|
||||
const positions = new Map();
|
||||
for (const line of plain.split('\n')) {
|
||||
if (!line.startsWith('node ')) continue;
|
||||
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
||||
if (!match) continue;
|
||||
positions.set(match[1], {
|
||||
x: Number(match[2]),
|
||||
y: Number(match[3]),
|
||||
width: Number(match[4]),
|
||||
height: Number(match[5]),
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function fixSelfLoopSvg(svg) {
|
||||
return svg.replace(
|
||||
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
|
||||
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
|
||||
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
|
||||
if (pts.length < 2) return match;
|
||||
|
||||
const [x0, y0] = pts[0];
|
||||
const [x1, y1] = pts[pts.length - 1];
|
||||
const east = Math.max(x0, x1) + 36;
|
||||
const north = Math.min(y0, y1) - 28;
|
||||
|
||||
const tx = x1 - east;
|
||||
const ty = y1 - north;
|
||||
const len = Math.hypot(tx, ty) || 1;
|
||||
const dx = tx / len;
|
||||
const dy = ty / len;
|
||||
const arrowLen = 9;
|
||||
const arrowHalf = 3.5;
|
||||
const baseX = x1 - dx * arrowLen;
|
||||
const baseY = y1 - dy * arrowLen;
|
||||
const px = -dy * arrowHalf;
|
||||
const py = dx * arrowHalf;
|
||||
|
||||
const fmt = (n) => (Math.round(n * 100) / 100).toString();
|
||||
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
|
||||
const newPointsFmt =
|
||||
`${fmt(x1)},${fmt(y1)} ` +
|
||||
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
|
||||
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
|
||||
`${fmt(x1)},${fmt(y1)}`;
|
||||
|
||||
return (
|
||||
`<g id="${edgeId}" class="edge">\n` +
|
||||
`<title>${_table}->${_table}</title>\n` +
|
||||
`<g id="${aId}"><a ${aAttrs}>\n` +
|
||||
`<path${pathAttrs} d="${newDFmt}"/>\n` +
|
||||
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
|
||||
`</a>\n` +
|
||||
`</g>\n` +
|
||||
`</g>`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Generating Dashfront Automat Graph…');
|
||||
|
||||
const specsMap = new Map();
|
||||
for (const spec of AUTOMAT_SPECS) {
|
||||
specsMap.set(spec.id, spec);
|
||||
}
|
||||
|
||||
// Add Legend pseudo-automat
|
||||
specsMap.set('Legend', {
|
||||
id: 'Legend',
|
||||
title: 'Legend',
|
||||
subtitle: '',
|
||||
entries: [],
|
||||
});
|
||||
|
||||
const edges = EDGE_DEFS.filter((def) => specsMap.has(def.from) && specsMap.has(def.to));
|
||||
|
||||
console.log(`Structured ${specsMap.size - 1} Automats across ${CLUSTERS.length} domain clusters with ${edges.length} relationships`);
|
||||
|
||||
const graphviz = await Graphviz.load();
|
||||
|
||||
// Pass 1: Initial layout to learn coordinates
|
||||
console.log('Layout pass 1/2: computing node positions…');
|
||||
const initialDot = buildDot(specsMap, edges, null, null);
|
||||
const plain = graphviz.layout(initialDot, 'plain', 'dot');
|
||||
const positions = parsePlainPositions(plain);
|
||||
|
||||
// Pass 2: Anchor assignment using learned positions
|
||||
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
|
||||
const anchors = assignEdgeAnchors(edges, positions);
|
||||
const finalDot = buildDot(specsMap, edges, positions, anchors);
|
||||
|
||||
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
|
||||
const fixedSvg = fixSelfLoopSvg(rawSvg);
|
||||
|
||||
// Ensure background color is set on svg tag
|
||||
const finalSvg = fixedSvg.replace(
|
||||
/<svg([^>]*?)>/,
|
||||
`<svg$1 style="background-color:${COLORS.background};">`
|
||||
);
|
||||
|
||||
writeFileSync(dotOutputPath, finalDot, 'utf8');
|
||||
writeFileSync(outputPath, finalSvg, 'utf8');
|
||||
|
||||
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||||
console.log(`Written ${dotOutputPath}`);
|
||||
console.log(`Written ${outputPath}`);
|
||||
if (dim) {
|
||||
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
|
||||
}
|
||||
196
index.html
196
index.html
@@ -1,56 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<meta name="theme-color" content="#3b5bdb" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Dashfront</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background-color: #c5ddd8;
|
||||
}
|
||||
<html lang="de" class="notranslate" translate="no">
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>QuixPOS * Management Interface</title>
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="title" content="QuixPOS * Management Interface" />
|
||||
<meta name="description"
|
||||
content="QuixPOS * Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung, Aufträge und Bestandsführung." />
|
||||
<meta name="keywords"
|
||||
content="QuixPOS, POS, Kassensystem, Warenwirtschaft, ERP, Artikelverwaltung, Lagerverwaltung, Kasse, Point of Sale" />
|
||||
<meta name="author" content="QuixPOS" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#1d4ed8" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
|
||||
<!-- Open Graph / Facebook / LinkedIn / WhatsApp -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="QuixPOS" />
|
||||
<meta property="og:title" content="QuixPOS * Management Interface" />
|
||||
<meta property="og:description"
|
||||
content="Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung und Bestandsführung." />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<meta property="og:image:secure_url" content="/og-image.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="QuixPOS * Point-of-Sale & Warenwirtschaftssystem" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="QuixPOS * Management Interface" />
|
||||
<meta name="twitter:description"
|
||||
content="Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung und Bestandsführung." />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="QuixPOS * Point-of-Sale & Warenwirtschaftssystem" />
|
||||
|
||||
<!-- Structured Data: JSON-LD -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
"@id": "#webapp",
|
||||
"name": "QuixPOS",
|
||||
"alternateName": "QuixPOS Management Interface",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "All",
|
||||
"browserRequirements": "Requires modern JavaScript browser (Chrome, Firefox, Safari, Edge)",
|
||||
"description": "Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung, Aufträge und Bestandsführung.",
|
||||
"image": "/og-image.png",
|
||||
"screenshot": "/og-image.png",
|
||||
"softwareVersion": "1.0",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"featureList": [
|
||||
"Point of Sale Kassenbetrieb",
|
||||
"Artikel- & Barcodeverwaltung",
|
||||
"Lager- & Lieferantenmanagement",
|
||||
"Angebote & Bestellwesen",
|
||||
"Kundenverwaltung & Preisgruppen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": "#organization",
|
||||
"name": "QuixPOS",
|
||||
"logo": "/quixpos-logo.png",
|
||||
"image": "/og-image.png",
|
||||
"description": "Point-of-Sale & Warenwirtschaftssystem"
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var host = location.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
var hadSw = false;
|
||||
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||
hadSw = regs.length > 0;
|
||||
return Promise.all(regs.map(function (r) { return r.unregister(); }));
|
||||
}).then(function () {
|
||||
if (!window.caches) return hadSw;
|
||||
return caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
|
||||
.then(function () { return hadSw; });
|
||||
});
|
||||
}).then(function (reload) {
|
||||
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
|
||||
sessionStorage.setItem('dashfront-sw-cleared', '1');
|
||||
location.reload();
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* First-paint colors. Mirrored from src/colors.js (MAIN_BACKGROUND /
|
||||
THEME_COLOR) because this file must paint before the JS bundle loads.
|
||||
Once the app boots, src/colors.js takes over via CSS custom properties. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background-color: #82b8b4;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<div
|
||||
style="font-family:system-ui,-apple-system,sans-serif;max-width:440px;margin:60px auto;padding:32px 24px;text-align:center;background:#ffffff;border-radius:16px;box-shadow:0 8px 30px rgba(0,0,0,0.12);color:#1e293b;">
|
||||
<img src="/quixpos-logo.png" alt="QuixPOS Logo" width="96" height="96"
|
||||
style="border-radius:16px;margin-bottom:16px;box-shadow:0 4px 12px rgba(0,0,0,0.08);" />
|
||||
<h1 style="margin:0 0 6px;font-size:26px;font-weight:700;color:#0f2f35;">QuixPOS</h1>
|
||||
<p style="margin:0 0 12px;font-size:16px;font-weight:600;color:#0f766e;">Management Interface</p>
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8;">Bitte aktivieren Sie JavaScript, um das Management Interface zu
|
||||
nutzen.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var host = location.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
var hadSw = false;
|
||||
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||
hadSw = regs.length > 0;
|
||||
return Promise.all(regs.map(function (r) { return r.unregister(); }));
|
||||
}).then(function () {
|
||||
if (!window.caches) return hadSw;
|
||||
return caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
|
||||
.then(function () { return hadSw; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
}).then(function (reload) {
|
||||
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
|
||||
sessionStorage.setItem('dashfront-sw-cleared', '1');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
2585
package-lock.json
generated
2585
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -10,34 +10,59 @@
|
||||
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
||||
"cert": "./scripts/generate-cert.sh",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||
"rig:collect": "node test/collect.mjs",
|
||||
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
|
||||
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
|
||||
"automat-graph": "node generate-automat-graph.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@jsquash/avif": "^2.1.1",
|
||||
"@jsquash/resize": "^2.1.1",
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@mui/icons-material": "^9.5.0",
|
||||
"@mui/material": "^9.5.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"automat": "file:../automat",
|
||||
"@uiw/react-codemirror": "^4.25.12",
|
||||
"dashgui": "file:../dashgui",
|
||||
"flexsearch": "^0.8.212",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"notistack": "^3.0.2",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"pdftotext-wasm": "file:../pdftotext-wasm",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-automat": "file:../automat",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-window": "^2.3.4",
|
||||
"skelehash-wasm": "^0.1.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^8.3.4",
|
||||
"vite-plugin-pwa": "^2.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"canvas@3.2.3": true,
|
||||
"react-canvas-label-editor@20.0.0": true,
|
||||
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/og-image.png
Normal file
BIN
public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
BIN
public/quixpos-logo.png
Normal file
BIN
public/quixpos-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
|
||||
import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
|
||||
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';
|
||||
|
||||
99
src/App.jsx
99
src/App.jsx
@@ -1,12 +1,16 @@
|
||||
import { Component, Suspense } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { PureComponent, lazy, Suspense } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
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';
|
||||
import AppRouter from './appRouter';
|
||||
|
||||
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
|
||||
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||
const SseMonitor = import.meta.env.DEV
|
||||
? lazy(() => import('./devtools/SseMonitor'))
|
||||
: null;
|
||||
|
||||
function PageFallback() {
|
||||
return (
|
||||
@@ -24,45 +28,96 @@ 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',
|
||||
'hasAuth',
|
||||
'hasCompany',
|
||||
'retryNotification',
|
||||
]);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, needsCompanySetup } = this.props;
|
||||
const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
|
||||
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
|
||||
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
minHeight: 'var(--df-vh, 100vh)',
|
||||
bgcolor: 'background.default',
|
||||
gap: 2.5,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{retryNotification ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
maxWidth: 480,
|
||||
alignItems: 'center',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 1,
|
||||
}}
|
||||
>
|
||||
{retryNotification}
|
||||
</Alert>
|
||||
) : null}
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'authed') {
|
||||
if (!isAuthed) {
|
||||
if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
|
||||
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
|
||||
}
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (needsCompanySetup) {
|
||||
return <CompanySetupDialog />;
|
||||
}
|
||||
const isOAuthAuthorize =
|
||||
typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
{!isCompanySelected ? (
|
||||
isOAuthAuthorize ? (
|
||||
<OAuthAuthorizePage />
|
||||
) : (
|
||||
<CompanySetupDialog />
|
||||
)
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</Suspense>
|
||||
{SseMonitor ? (
|
||||
<Suspense fallback={null}>
|
||||
<SseMonitor />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
status: selectAuthStatus(state),
|
||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(App);
|
||||
export default App;
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
function enc(value) {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl, path) {
|
||||
return new URL(path, baseUrl || window.location.origin).href;
|
||||
}
|
||||
|
||||
function buildPath(path, query) {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null) continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty).
|
||||
*/
|
||||
export class ApiClient {
|
||||
#baseUrl;
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
this.#baseUrl = baseUrl;
|
||||
|
||||
this.auth = {
|
||||
/** POST /api/auth/login */
|
||||
login: (body) => this.#json('POST', '/api/auth/login', { body }),
|
||||
/** POST /api/auth/logout */
|
||||
logout: () => this.#json('POST', '/api/auth/logout'),
|
||||
/** GET /api/auth/me */
|
||||
me: () => this.#json('GET', '/api/auth/me'),
|
||||
/** PUT /api/auth/selected-company */
|
||||
selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
|
||||
};
|
||||
|
||||
this.health = {
|
||||
/** GET /api/health */
|
||||
get: () => this.#json('GET', '/api/health'),
|
||||
};
|
||||
|
||||
this.company = {
|
||||
/** GET /api/company */
|
||||
list: () => this.#json('GET', '/api/company'),
|
||||
/** POST /api/company */
|
||||
create: (body) => this.#json('POST', '/api/company', { body }),
|
||||
/** PUT /api/company/{id} */
|
||||
update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
|
||||
/** DELETE /api/company/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.unit = {
|
||||
/** GET /api/unit */
|
||||
list: () => this.#json('GET', '/api/unit'),
|
||||
/** PUT /api/unit */
|
||||
upsert: (body) => this.#json('PUT', '/api/unit', { body }),
|
||||
/** DELETE /api/unit?si=… */
|
||||
delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
|
||||
};
|
||||
|
||||
this.brand = {
|
||||
/** GET /api/brand */
|
||||
list: () => this.#json('GET', '/api/brand'),
|
||||
/** PUT /api/brand */
|
||||
upsert: (body) => this.#json('PUT', '/api/brand', { body }),
|
||||
/** DELETE /api/brand/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.supplier = {
|
||||
/** GET /api/supplier → { suppliers: Supplier[] } */
|
||||
list: () => this.#json('GET', '/api/supplier'),
|
||||
/** POST /api/supplier → 201 { supplier } */
|
||||
create: (body) => this.#json('POST', '/api/supplier', { body }),
|
||||
/** PATCH /api/supplier/{id} → { supplier } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
|
||||
/** DELETE /api/supplier/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
|
||||
/** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */
|
||||
getArticles: (id, skus) => {
|
||||
let query = '';
|
||||
if (skus != null) {
|
||||
const list = Array.isArray(skus) ? skus : [skus];
|
||||
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
|
||||
if (filtered.length > 0) {
|
||||
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
|
||||
}
|
||||
}
|
||||
return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`);
|
||||
},
|
||||
};
|
||||
|
||||
this.delivery = {
|
||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
||||
list: () => this.#json('GET', '/api/delivery'),
|
||||
/** GET /api/delivery/{id} → { delivery: Delivery } */
|
||||
get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
|
||||
/** POST /api/delivery → 201 { delivery: Delivery } */
|
||||
create: (body) => this.#json('POST', '/api/delivery', { body }),
|
||||
/** PATCH /api/delivery/{id} → { delivery: Delivery } */
|
||||
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)}`),
|
||||
/**
|
||||
* 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) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // 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
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
this.quote = {
|
||||
/** GET /api/quote → { quotes } */
|
||||
list: () => this.#json('GET', '/api/quote'),
|
||||
/** GET /api/quote/{id} → { quote } */
|
||||
get: (id) => this.#json('GET', `/api/quote/${enc(id)}`),
|
||||
/** POST /api/quote → 201 { quote } */
|
||||
create: (body) => this.#json('POST', '/api/quote', { body }),
|
||||
/** PATCH /api/quote/{id} → { quote } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }),
|
||||
/** DELETE /api/quote/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.order = {
|
||||
/** GET /api/order → { orders } */
|
||||
list: () => this.#json('GET', '/api/order'),
|
||||
/** GET /api/order/{id} → { order } */
|
||||
get: (id) => this.#json('GET', `/api/order/${enc(id)}`),
|
||||
/** POST /api/order → 201 { order } */
|
||||
create: (body) => this.#json('POST', '/api/order', { body }),
|
||||
/** PATCH /api/order/{id} → { order } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }),
|
||||
/** DELETE /api/order/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.customergroup = {
|
||||
/** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */
|
||||
list: () => this.#json('GET', '/api/customergroup'),
|
||||
/** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */
|
||||
create: (body) => this.#json('POST', '/api/customergroup', { body }),
|
||||
/** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */
|
||||
update: (id, body) =>
|
||||
this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }),
|
||||
/** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */
|
||||
order: (body) => this.#json('PUT', '/api/customergroup/order', { body }),
|
||||
/** DELETE /api/customergroup/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.entity = {
|
||||
/** GET /api/entity → { entities } · optional query customergroupId */
|
||||
list: (query) => this.#json('GET', '/api/entity', { query }),
|
||||
/** GET /api/entity/{id} → { entity } */
|
||||
get: (id) => this.#json('GET', `/api/entity/${enc(id)}`),
|
||||
/** POST /api/entity → 201 { entity } */
|
||||
create: (body) => this.#json('POST', '/api/entity', { body }),
|
||||
/** PATCH /api/entity/{id} → { entity } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
|
||||
/** DELETE /api/entity/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
|
||||
/** GET /api/entity/next-number → { number: string } */
|
||||
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
|
||||
/** GET /api/entity/{entityId}/address → { addresses } */
|
||||
listAddresses: (entityId) =>
|
||||
this.#json('GET', `/api/entity/${enc(entityId)}/address`),
|
||||
/** POST /api/entity/{entityId}/address → 201 { address } */
|
||||
createAddress: (entityId, body) =>
|
||||
this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }),
|
||||
/** PATCH /api/entity/{entityId}/address/{id} → { address } */
|
||||
updateAddress: (entityId, id, body) =>
|
||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, {
|
||||
body,
|
||||
}),
|
||||
/** DELETE /api/entity/{entityId}/address/{id} → 204 */
|
||||
deleteAddress: (entityId, id) =>
|
||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`),
|
||||
/** GET /api/entity/{entityId}/contact → { contacts } */
|
||||
listContacts: (entityId) =>
|
||||
this.#json('GET', `/api/entity/${enc(entityId)}/contact`),
|
||||
/** POST /api/entity/{entityId}/contact → 201 { contact } */
|
||||
createContact: (entityId, body) =>
|
||||
this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }),
|
||||
/** PATCH /api/entity/{entityId}/contact/{id} → { contact } */
|
||||
updateContact: (entityId, id, body) =>
|
||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, {
|
||||
body,
|
||||
}),
|
||||
/** DELETE /api/entity/{entityId}/contact/{id} → 204 */
|
||||
deleteContact: (entityId, id) =>
|
||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.category = {
|
||||
/** GET /api/category/tree */
|
||||
tree: () => this.#json('GET', '/api/category/tree'),
|
||||
/** GET /api/category/export.zip */
|
||||
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
|
||||
/** POST /api/category */
|
||||
create: (body) => this.#json('POST', '/api/category', { body }),
|
||||
/** GET /api/category/{id}/articles */
|
||||
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
|
||||
/** PATCH /api/category/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
|
||||
/** DELETE /api/category/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
|
||||
/** PUT /api/category/order */
|
||||
order: (body) => this.#json('PUT', '/api/category/order', { body }),
|
||||
/** POST /api/category/{id}/image — raw AVIF body */
|
||||
addImage: (id, avifBytes) =>
|
||||
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
||||
body: avifBytes,
|
||||
contentType: 'image/avif',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/category/{id}/image */
|
||||
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
|
||||
};
|
||||
|
||||
this.article = {
|
||||
/** GET /api/articleIndex */
|
||||
index: () => this.#json('GET', '/api/articleIndex'),
|
||||
/** POST /api/article */
|
||||
create: (body) => this.#json('POST', '/api/article', { body }),
|
||||
/** GET /api/article/export.zip */
|
||||
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
|
||||
/** GET /api/article/{sku} */
|
||||
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
||||
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
||||
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
||||
/**
|
||||
* Load history and refetch on SSE `article-<sku>`.
|
||||
* Paginates in StorageManager until the HTTP API supports skip/offset.
|
||||
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
||||
*/
|
||||
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
||||
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
|
||||
update: async (sku, body) => {
|
||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||
if (nameChange) {
|
||||
this.setArticleIndexName(sku, body.name);
|
||||
this.setArticleName(sku, body.name);
|
||||
}
|
||||
try {
|
||||
const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body });
|
||||
this.setArticle(res.article);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
this.clearArticleIndex();
|
||||
if (this.getArticle(sku)) {
|
||||
void this.loadArticle(sku).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/** DELETE /api/article/{sku} */
|
||||
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
|
||||
/** PUT /api/article/{sku}/prices */
|
||||
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
|
||||
/** POST /api/article/{sku}/image — raw AVIF body */
|
||||
addImage: (sku, avifBytes) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
||||
body: avifBytes,
|
||||
contentType: 'image/avif',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/article/{sku}/image/{fullHash} */
|
||||
deleteImage: (sku, fullHash) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
|
||||
/** PUT /api/article/{sku}/images/order */
|
||||
orderImages: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
|
||||
/** PUT /api/article/{sku}/eans */
|
||||
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
|
||||
/** PUT /api/article/{sku}/brand */
|
||||
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
|
||||
/** PUT /api/article/{sku}/weight */
|
||||
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
|
||||
/** PUT /api/article/{sku}/categories */
|
||||
setCategories: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
||||
/** GET /api/article/{sku}/supplier */
|
||||
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
||||
/** POST /api/article/{sku}/supplier */
|
||||
addSupplier: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
|
||||
/** PATCH /api/article/{sku}/supplier/{id} */
|
||||
updateSupplier: (sku, id, body) =>
|
||||
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
|
||||
/** PUT /api/article/{sku}/supplier/order */
|
||||
orderSuppliers: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
|
||||
/** DELETE /api/article/{sku}/supplier/{id} */
|
||||
deleteSupplier: (sku, id) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.posterminal = {
|
||||
/** GET /api/posterminal */
|
||||
list: () => this.#json('GET', '/api/posterminal'),
|
||||
/** PUT /api/posterminal */
|
||||
upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
|
||||
/** DELETE /api/posterminal/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
|
||||
/** PUT /api/posterminal/{id}/category/{categoryId} */
|
||||
setOn: (id, categoryId) =>
|
||||
this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
||||
/** DELETE /api/posterminal/{id}/category/{categoryId} */
|
||||
setOff: (id, categoryId) =>
|
||||
this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
||||
/** PUT /api/posterminal/{id}/all */
|
||||
setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
|
||||
/** DELETE /api/posterminal/{id}/all */
|
||||
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
|
||||
};
|
||||
|
||||
this.number = {
|
||||
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
|
||||
list: () => this.#json('GET', '/api/number'),
|
||||
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
|
||||
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
||||
};
|
||||
|
||||
this.setting = {
|
||||
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
||||
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
||||
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
|
||||
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
|
||||
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
|
||||
upsert: (key, body) =>
|
||||
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
|
||||
};
|
||||
|
||||
this.image = {
|
||||
/** GET /api/image/{fullHash} → AVIF blob */
|
||||
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
|
||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
|
||||
};
|
||||
|
||||
this.thumb = {
|
||||
/** GET /api/thumb/{fullHash} → AVIF blob */
|
||||
get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
|
||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Absolute URL for a same-origin API path (e.g. SSE). */
|
||||
urlFor(path) {
|
||||
return buildUrl(this.#baseUrl, path);
|
||||
}
|
||||
|
||||
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const headers = { ...extraHeaders };
|
||||
let payload;
|
||||
if (body !== undefined) {
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
payload = body;
|
||||
} else if (raw) {
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
payload = body;
|
||||
} else {
|
||||
headers['Content-Type'] = contentType || 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = url;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async #empty(method, path, { query } = {}) {
|
||||
await this.#json(method, path, { query });
|
||||
return null;
|
||||
}
|
||||
|
||||
async #blob(method, path, { query } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = url;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
@@ -1,634 +0,0 @@
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { parseCategoryTreeResponse } from '../store/categorySlice';
|
||||
import { ApiClient } from './ApiClient';
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||
* swaps only after a successful reload. Failed updates leave the cache intact.
|
||||
* replace() writes local data without a round-trip.
|
||||
*/
|
||||
function createCache(label, load) {
|
||||
/** Settled value, or `undefined` if not loaded yet / last load failed. */
|
||||
let current;
|
||||
let promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] update ${label} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(data) {
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
current = undefined;
|
||||
promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
get promise() {
|
||||
return promise;
|
||||
},
|
||||
/** Loaded value, or `undefined` if the initial load has not settled successfully. */
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
|
||||
*/
|
||||
export class StorageManager extends ApiClient {
|
||||
/** @type {EventSource | null} */
|
||||
#events = null;
|
||||
/** @type {ReturnType<typeof createCache> | null} */
|
||||
#articleIndexCache = null;
|
||||
/** @type {Map<string, object>} sku → article detail */
|
||||
#articlesBySku = new Map();
|
||||
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
|
||||
#articleWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
||||
#historyWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
||||
#supplierWatchers = new Map();
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
super(baseUrl);
|
||||
this.#initCaches();
|
||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
||||
this.article.supplierWatch = (sku, onSuppliers) =>
|
||||
this.watchArticleSuppliers(sku, onSuppliers);
|
||||
}
|
||||
|
||||
/** Settled article detail for sku, or undefined. */
|
||||
getArticle(sku) {
|
||||
return this.#articlesBySku.get(String(sku));
|
||||
}
|
||||
|
||||
/** Keep local article detail in sync after GET/PATCH/PUT responses. */
|
||||
setArticle(article) {
|
||||
requireObject(article, 'article');
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
this.#articlesBySku.set(key, article);
|
||||
const watchers = this.#articleWatchers.get(key);
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(article);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/article/{sku}, cache detail, return article.
|
||||
* @param {string} sku
|
||||
*/
|
||||
async loadArticle(sku) {
|
||||
const res = await this.article.get(sku);
|
||||
requireObject(res, 'GET /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
return this.setArticle(res.article);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
|
||||
* @param {string} sku
|
||||
* @param {(article: object) => void} onArticle
|
||||
* @returns {{ article: object | null, unwatch: () => void }}
|
||||
*/
|
||||
watchArticle(sku, onArticle) {
|
||||
const key = String(sku);
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#articleWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#articleWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(onArticle);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
article: this.getArticle(key) ?? null,
|
||||
unwatch: () => {
|
||||
const set = this.#articleWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(onArticle);
|
||||
if (set.size === 0) {
|
||||
this.#articleWatchers.delete(key);
|
||||
if (
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed category map + root id from settled caches, or null if any piece missing.
|
||||
* @returns {{ categories: object, rootCategoryId: unknown } | null}
|
||||
*/
|
||||
getCategoryState() {
|
||||
const treeData = this.categoryTree;
|
||||
const companies = this.companies;
|
||||
const session = this.authMe;
|
||||
if (treeData == null || companies == null || session == null) return null;
|
||||
|
||||
const companyId = session.user?.selectedCompanyId;
|
||||
if (companyId == null) return null;
|
||||
const company = companies.find((c) => c.id === companyId);
|
||||
if (company == null) return null;
|
||||
|
||||
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
|
||||
return {
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
||||
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
|
||||
* @param {string} sku
|
||||
* @param {(history: unknown[], total: number) => void} onHistory
|
||||
* @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
|
||||
*/
|
||||
watchArticleHistory(sku, onHistory) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
let loaded = false;
|
||||
/** @type {unknown[]} */
|
||||
let all = [];
|
||||
let skip = 0;
|
||||
/** Page size (how many rows to emit). */
|
||||
let offset = Number.POSITIVE_INFINITY;
|
||||
|
||||
const emit = () => {
|
||||
if (!active || !loaded) return;
|
||||
const total = all.length;
|
||||
const end =
|
||||
offset === Number.POSITIVE_INFINITY ? total : skip + offset;
|
||||
onHistory(all.slice(skip, end), total);
|
||||
};
|
||||
|
||||
const refresh = () =>
|
||||
this.article.history(key).then((res) => {
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/history');
|
||||
all = requireArray(res.history, 'history');
|
||||
loaded = true;
|
||||
emit();
|
||||
});
|
||||
|
||||
const run = () =>
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#historyWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#historyWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#historyWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#historyWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
setSkipAndOffset: (nextSkip, nextOffset) => {
|
||||
skip = nextSkip;
|
||||
offset = nextOffset;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppliers for an article and refetch on SSE `article-<sku>`.
|
||||
* @param {string} sku
|
||||
* @param {(suppliers: Array<object>) => void} onSuppliers
|
||||
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
|
||||
*/
|
||||
watchArticleSuppliers(sku, onSuppliers) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await this.article.getSuppliers(key);
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/supplier');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
onSuppliers(list);
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
};
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#supplierWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#supplierWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#supplierWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#supplierWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#onArticleSignal = (event) => {
|
||||
const sku = event.type.slice('article-'.length);
|
||||
if (this.#articleWatchers.has(sku)) {
|
||||
void this.loadArticle(sku).catch((err) => notifyError(err));
|
||||
}
|
||||
const historyWatchers = this.#historyWatchers.get(sku);
|
||||
if (historyWatchers) {
|
||||
for (const run of historyWatchers) run();
|
||||
}
|
||||
const supplierWatchers = this.#supplierWatchers.get(sku);
|
||||
if (supplierWatchers) {
|
||||
for (const run of supplierWatchers) run();
|
||||
}
|
||||
};
|
||||
|
||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
||||
setArticleIndexName(sku, name) {
|
||||
const articles = this.#articleIndexCache.value;
|
||||
if (!articles) return;
|
||||
this.#articleIndexCache.replace(
|
||||
articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Patch local article detail name if already cached; no-op otherwise. */
|
||||
setArticleName(sku, name) {
|
||||
const current = this.#articlesBySku.get(String(sku));
|
||||
if (!current) return;
|
||||
this.setArticle({ ...current, name });
|
||||
}
|
||||
|
||||
/** Drop local articleIndex if loaded (forces reload on next read). */
|
||||
clearArticleIndex() {
|
||||
if (!this.#articleIndexCache.value) return;
|
||||
this.#articleIndexCache.clear();
|
||||
}
|
||||
|
||||
#initCaches() {
|
||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
||||
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
|
||||
const articleIndexCache = this.#articleIndexCache;
|
||||
const brandCache = createCache('GET /brand', () => this.#loadBrands());
|
||||
const unitCache = createCache('GET /unit', () => this.#loadUnits());
|
||||
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(),
|
||||
);
|
||||
const entityCache = createCache('GET /entity', () => this.#loadEntities());
|
||||
const numberCache = createCache('GET /number', () => this.#loadNumbers());
|
||||
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
|
||||
const orderCache = createCache('GET /order', () => this.#loadOrders());
|
||||
|
||||
Object.defineProperties(this, {
|
||||
authMePromise: { get: () => authMeCache.promise },
|
||||
updateAuthMe: { value: authMeCache.update },
|
||||
/** Settled GET /auth/me body, or undefined. */
|
||||
authMe: { get: () => authMeCache.value },
|
||||
|
||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
||||
updateArticleIndex: { value: articleIndexCache.update },
|
||||
|
||||
brandPromise: { get: () => brandCache.promise },
|
||||
updateBrand: { value: brandCache.update },
|
||||
/** Settled GET /brand list, or undefined. */
|
||||
brands: { get: () => brandCache.value },
|
||||
|
||||
unitPromise: { get: () => unitCache.promise },
|
||||
updateUnit: { value: unitCache.update },
|
||||
/** Settled GET /unit list, or undefined. */
|
||||
units: { get: () => unitCache.value },
|
||||
|
||||
companyPromise: { get: () => companyCache.promise },
|
||||
updateCompany: { value: companyCache.update },
|
||||
/** Settled GET /company list, or undefined. */
|
||||
companies: { get: () => companyCache.value },
|
||||
|
||||
categoryTreePromise: { get: () => categoryTreeCache.promise },
|
||||
updateCategoryTree: { value: categoryTreeCache.update },
|
||||
/** Settled GET /category/tree body, or undefined. */
|
||||
categoryTree: { get: () => categoryTreeCache.value },
|
||||
|
||||
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. */
|
||||
deliveries: { get: () => deliveryCache.value },
|
||||
|
||||
customergroupPromise: { get: () => customergroupCache.promise },
|
||||
updateCustomergroup: { value: customergroupCache.update },
|
||||
/** Settled GET /customergroup list, or undefined. */
|
||||
customergroups: { get: () => customergroupCache.value },
|
||||
|
||||
entityPromise: { get: () => entityCache.promise },
|
||||
updateEntity: { value: entityCache.update },
|
||||
/** Settled GET /entity list, or undefined. */
|
||||
entities: { get: () => entityCache.value },
|
||||
|
||||
numberPromise: { get: () => numberCache.promise },
|
||||
updateNumber: { value: numberCache.update },
|
||||
/** Settled GET /number list, or undefined. */
|
||||
numbers: { get: () => numberCache.value },
|
||||
|
||||
quotePromise: { get: () => quoteCache.promise },
|
||||
updateQuote: { value: quoteCache.update },
|
||||
/** Settled GET /quote list, or undefined. */
|
||||
quotes: { get: () => quoteCache.value },
|
||||
|
||||
orderPromise: { get: () => orderCache.promise },
|
||||
updateOrder: { value: orderCache.update },
|
||||
/** Settled GET /order list, or undefined. */
|
||||
orders: { get: () => orderCache.value },
|
||||
|
||||
/** Close and reopen GET /api/events (required after selected-company change). */
|
||||
reconnectEvents: { value: () => this.#reconnectEvents() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events — SSE signals for the selected company.
|
||||
* Stream is bound at connect time; reconnect after company switch.
|
||||
*/
|
||||
#reconnectEvents() {
|
||||
this.#disconnectEvents();
|
||||
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
||||
es.addEventListener('category', () => {
|
||||
void this.updateCategoryTree().catch((err) => {
|
||||
console.error('[storageMgr] category SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('articles', () => {
|
||||
void this.updateArticleIndex().catch((err) => {
|
||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
for (const sku of new Set([
|
||||
...this.#articleWatchers.keys(),
|
||||
...this.#historyWatchers.keys(),
|
||||
...this.#supplierWatchers.keys(),
|
||||
])) {
|
||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
||||
}
|
||||
es.onerror = () => {
|
||||
console.error('[storageMgr] events stream error');
|
||||
};
|
||||
this.#events = es;
|
||||
}
|
||||
|
||||
#disconnectEvents() {
|
||||
if (!this.#events) return;
|
||||
this.#events.close();
|
||||
this.#events = null;
|
||||
}
|
||||
|
||||
#syncEvents(user) {
|
||||
if (user?.selectedCompanyId != null) {
|
||||
this.#reconnectEvents();
|
||||
} else {
|
||||
this.#disconnectEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async #loadAuthMe() {
|
||||
try {
|
||||
const data = await this.auth.me();
|
||||
requireObject(data, 'GET /api/auth/me');
|
||||
const session = {
|
||||
user: data.user,
|
||||
googleClientId: data.googleClientId,
|
||||
};
|
||||
this.#syncEvents(session.user);
|
||||
return session;
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
this.#syncEvents(null);
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadArticleIndex() {
|
||||
const data = await this.article.index();
|
||||
requireObject(data, 'GET /api/articleIndex');
|
||||
const articles = requireArray(data.articles, 'articleIndex.articles');
|
||||
// Map backend rows to the recordset shape used by the result list.
|
||||
// `categories` is a comma-joined string (formatCategoryList splits on ',').
|
||||
return articles.map((a) => ({
|
||||
id: a.id,
|
||||
sku: a.sku,
|
||||
name: a.name,
|
||||
eans: a.eans ?? [],
|
||||
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async #loadBrands() {
|
||||
const data = await this.brand.list();
|
||||
requireObject(data, 'GET /api/brand');
|
||||
return requireArray(data.brands, 'brands');
|
||||
}
|
||||
|
||||
async #loadUnits() {
|
||||
const data = await this.unit.list();
|
||||
requireObject(data, 'GET /api/unit');
|
||||
return requireArray(data.units, 'units');
|
||||
}
|
||||
|
||||
async #loadCompanies() {
|
||||
const data = await this.company.list();
|
||||
requireObject(data, 'GET /api/company');
|
||||
return requireArray(data.companies, 'companies');
|
||||
}
|
||||
|
||||
async #loadCategoryTree() {
|
||||
const data = await this.category.tree();
|
||||
requireObject(data, 'GET /api/category/tree');
|
||||
requireArray(data.tree, 'category/tree.tree');
|
||||
requireArray(data.articles, 'category/tree.articles');
|
||||
requireDefined(data.languageId, 'category/tree.languageId');
|
||||
return data;
|
||||
}
|
||||
|
||||
async #loadPosterminals() {
|
||||
const data = await this.posterminal.list();
|
||||
requireObject(data, 'GET /api/posterminal');
|
||||
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');
|
||||
return requireArray(data.deliveries, 'deliveries');
|
||||
}
|
||||
|
||||
async #loadCustomergroups() {
|
||||
const data = await this.customergroup.list();
|
||||
requireObject(data, 'GET /api/customergroup');
|
||||
return requireArray(data.customergroups, 'customergroups');
|
||||
}
|
||||
|
||||
async #loadEntities() {
|
||||
const data = await this.entity.list();
|
||||
requireObject(data, 'GET /api/entity');
|
||||
return requireArray(data.entities, 'entities');
|
||||
}
|
||||
|
||||
async #loadNumbers() {
|
||||
const data = await this.number.list();
|
||||
requireObject(data, 'GET /api/number');
|
||||
return requireArray(data.numbers, 'numbers');
|
||||
}
|
||||
|
||||
async #loadQuotes() {
|
||||
const data = await this.quote.list();
|
||||
requireObject(data, 'GET /api/quote');
|
||||
return requireArray(data.quotes, 'quotes');
|
||||
}
|
||||
|
||||
async #loadOrders() {
|
||||
const data = await this.order.list();
|
||||
requireObject(data, 'GET /api/order');
|
||||
return requireArray(data.orders, 'orders');
|
||||
}
|
||||
|
||||
async doSearch({ target, filter, offset, maxCount, onUpdate }) {
|
||||
const articles = await this.articleIndexPromise;
|
||||
const query = target.trim().toLowerCase();
|
||||
const matches = articles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.sku.toLowerCase().includes(query) ||
|
||||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
|
||||
);
|
||||
onUpdate({
|
||||
categoryFacets: {},
|
||||
totalCount: matches.length,
|
||||
recordset: matches,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageManager;
|
||||
24
src/api/account.js
Normal file
24
src/api/account.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company payment accounts.
|
||||
*/
|
||||
export const account = {
|
||||
// #region GET /api/account — list
|
||||
list() {
|
||||
return http('/api/account');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/account — create
|
||||
create(body) {
|
||||
return http('/api/account', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/account/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/account/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
69
src/api/agentprinter.js
Normal file
69
src/api/agentprinter.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const agentprinter = {
|
||||
/**
|
||||
* GET /api/agentprinter
|
||||
* Query: ?printeragentId=<id>
|
||||
* → { agentprinters: Agentprinter[] }
|
||||
*/
|
||||
list(printeragentId) {
|
||||
const url =
|
||||
printeragentId != null
|
||||
? `/api/agentprinter?printeragentId=${encodeURIComponent(printeragentId)}`
|
||||
: '/api/agentprinter';
|
||||
return http(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/agentprinter/:id
|
||||
* → { agentprinter: Agentprinter }
|
||||
*/
|
||||
get(id) {
|
||||
return http(`/api/agentprinter/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/agentprinter/:id
|
||||
* Body: { enabled?: boolean, sort?: number }
|
||||
* → { agentprinter: Agentprinter }
|
||||
*/
|
||||
update(id, patch = {}) {
|
||||
const body = {};
|
||||
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||
body.sort = Number(patch.sort);
|
||||
}
|
||||
return http(`/api/agentprinter/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/agentprinter/order
|
||||
* Body: { printeragentId?: number, agentprinterIds: number[] }
|
||||
* → { agentprinterIds: number[] }
|
||||
*/
|
||||
order({ printeragentId, agentprinterIds }) {
|
||||
const body = {
|
||||
agentprinterIds: Array.isArray(agentprinterIds) ? agentprinterIds.map(Number) : [],
|
||||
};
|
||||
if (printeragentId != null) {
|
||||
body.printeragentId = Number(printeragentId);
|
||||
}
|
||||
return http('/api/agentprinter/order', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/agentprinter/:id → 204
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/agentprinter/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
409
src/api/article.js
Normal file
409
src/api/article.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { http, httpBinary, httpGetBinary } from './http.js';
|
||||
|
||||
/**
|
||||
* NOTE: there is no `articleIndex` namespace anymore.
|
||||
*
|
||||
* The whole-company search index (`GET /api/articleIndex`) was abandoned. Article
|
||||
* lists are fetched per category via `category.getArticles(categoryId)`
|
||||
* (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every
|
||||
* category view right after the tree loads, and `articleIndexAutomat` derives the
|
||||
* client-side search index from those views.
|
||||
*/
|
||||
|
||||
export const article = {
|
||||
search({ target, filter, offset, maxCount } = {}) {
|
||||
const params = new URLSearchParams({ target });
|
||||
if (filter) params.set('filter', filter);
|
||||
if (offset) params.set('offset', String(offset));
|
||||
if (maxCount != null) params.set('maxCount', String(maxCount));
|
||||
return http(`/api/article/search?${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku → { article }
|
||||
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
|
||||
* (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.)
|
||||
*/
|
||||
getBySku(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||
*/
|
||||
// #region GET /api/article/history — getHistory
|
||||
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* @param {string} sku
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
|
||||
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
|
||||
*/
|
||||
getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
/** POST /api/article → 201 { sku } */
|
||||
create({ categoryId, name, track_stock }) {
|
||||
return http('/api/article', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
categoryId: Number(categoryId),
|
||||
name,
|
||||
...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku
|
||||
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
|
||||
* unitSi must exist for selected company → 404 Unit not found
|
||||
* → 204 (no body; refetch via getBySku)
|
||||
*/
|
||||
update(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/track_stock
|
||||
* Body: { track_stock: boolean }
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setTrackStock(sku, body) {
|
||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku → 204
|
||||
* Errors: 400 Article has children, 401, 403, 404 Article not found
|
||||
*/
|
||||
delete(sku, { cascade = false } = {}) {
|
||||
const qs = cascade ? '?cascade=true' : '';
|
||||
return http(`/api/article/${encodeURIComponent(sku)}${qs}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/image
|
||||
* Body: raw image bytes (Content-Type: image/webp, max 1000×1000)
|
||||
* → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } }
|
||||
*/
|
||||
uploadImage(sku, file) {
|
||||
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
|
||||
method: 'POST',
|
||||
contentType: file?.type || 'image/webp',
|
||||
body: file,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/image/:fullHash → 204
|
||||
*/
|
||||
deleteImage(sku, fullHash) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/images/order
|
||||
* Body: { fullHashes: string[] } (every image exactly once)
|
||||
* → 200 { fullHashes: string[] }
|
||||
*/
|
||||
orderImages(sku, fullHashes) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/images/order`, {
|
||||
method: 'PUT',
|
||||
body: { fullHashes },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/prices
|
||||
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
|
||||
* Replace-all POS customer group prices. Empty prices clears all POS prices.
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setPrices(sku, prices) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
|
||||
method: 'PUT',
|
||||
body: { prices },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight
|
||||
* Body: { weight } → 204 (no body)
|
||||
*/
|
||||
setWeight(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand
|
||||
* Body: { brandId: number | null } — null clears the brand.
|
||||
* brandId must belong to the article's company → 404 Article or brand not found.
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setBrandIdBySku(sku, brandId) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
|
||||
method: 'PUT',
|
||||
body: { brandId: brandId != null ? Number(brandId) : null },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/eans
|
||||
* Body: { eans: string[] } — replace-all. Empty array clears all.
|
||||
* EANs must be non-empty unique strings → 400 Invalid eans.
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setEansBySku(sku, eans) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
|
||||
method: 'PUT',
|
||||
body: { eans },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/categories
|
||||
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
|
||||
* Categories must belong to the article's company; ids must be unique.
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setCategories(sku, categoryIds) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
|
||||
method: 'PUT',
|
||||
body: { categoryIds },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] }
|
||||
*/
|
||||
getSuppliers(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/supplier
|
||||
* Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
|
||||
* → 201 { supplier: ArticleSupplier }
|
||||
*/
|
||||
addSupplier(sku, body) {
|
||||
const payload = {
|
||||
...body,
|
||||
entityId: Number(body?.entityId),
|
||||
};
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku/supplier/:id
|
||||
* Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
|
||||
* → 200 { supplier: ArticleSupplier }
|
||||
*/
|
||||
updateSupplier(sku, id, body) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/supplier/order
|
||||
* Body: { ids: number[] }
|
||||
* → 200 { ids: number[] }
|
||||
*/
|
||||
orderSuppliers(sku, ids) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, {
|
||||
method: 'PUT',
|
||||
body: { ids },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/supplier/:id → 204
|
||||
*/
|
||||
deleteSupplier(sku, id) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/export.zip → application/zip (binary download)
|
||||
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
|
||||
* one `<fullHash>.avif` per article image (content-addressed, deduplicated).
|
||||
* Optional `categoryId` restricts the export to that category and below.
|
||||
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||
*/
|
||||
exportZip(categoryId) {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId != null) params.set('categoryId', String(categoryId));
|
||||
const qs = params.toString();
|
||||
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/article/:sku/print
|
||||
* Body: { printerId, templateId, quantity }
|
||||
* Prints the given template to the specified printer for this article.
|
||||
*/
|
||||
printLabel(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/print`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* GET /api/article/:sku/attributevalue → { attributevalues: ArticleAttributeValue[] }
|
||||
*/
|
||||
getAttributeValues(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/attributevalue
|
||||
* Body: { attributevalues: number[] | { attributeValueId: number, sort?: number }[] }
|
||||
* → 200 { attributevalues: ArticleAttributeValue[] }
|
||||
*/
|
||||
setAttributeValues(sku, attributevalues) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`, {
|
||||
method: 'PUT',
|
||||
body: { attributevalues },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/property → { properties: ArticleProperty[] }
|
||||
*/
|
||||
getProperties(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/property`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/property
|
||||
* Body: { properties: { propertyId: number, value: string, sort?: number }[] }
|
||||
* → 200 { properties: ArticleProperty[] }
|
||||
*/
|
||||
setProperties(sku, properties) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/property`, {
|
||||
method: 'PUT',
|
||||
body: { properties },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/property/:propertyId → 200 { ok: true }
|
||||
*/
|
||||
deleteProperty(sku, propertyId) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/property/${encodeURIComponent(propertyId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/parent
|
||||
* Body: { isParent: boolean, attributeNameId?: number } → 204 (no body)
|
||||
*/
|
||||
toggleParent(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/children
|
||||
* Body: { attributeValueId?: number, value?: string, name?: string }
|
||||
* → 201 { sku }
|
||||
*/
|
||||
addChild(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/children/:childSku
|
||||
*/
|
||||
removeChild(sku, childSku, options = {}) {
|
||||
const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/articlelist
|
||||
* Body: { items: [{ articleId, sku, count, sort }] }
|
||||
*/
|
||||
setArticlelist(sku, items) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, {
|
||||
method: 'PUT',
|
||||
body: Array.isArray(items) ? { items } : items,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/optionslist
|
||||
* Body: { options: [{ articleId, sku, count, sort, mandated }] }
|
||||
*/
|
||||
setOptionslist(sku, options) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, {
|
||||
method: 'PUT',
|
||||
body: Array.isArray(options) ? { options } : options,
|
||||
});
|
||||
},
|
||||
};
|
||||
38
src/api/attributename.js
Normal file
38
src/api/attributename.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const attributename = {
|
||||
/** GET /api/attributename → { attributenames: Attributename[] } */
|
||||
// #region GET /api/attributename — list
|
||||
list() {
|
||||
return http('/api/attributename');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/attributename → 201 { attributename } */
|
||||
// #region POST /api/attributename — create
|
||||
create(body) {
|
||||
return http('/api/attributename', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PATCH /api/attributename/:id → 200 { attributename } */
|
||||
// #region PATCH /api/attributename/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/attributename/:id → 200 */
|
||||
// #region DELETE /api/attributename/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PUT /api/attributename/order → 200 */
|
||||
// #region PUT /api/attributename/order — order
|
||||
order(attributenameIds) {
|
||||
return http('/api/attributename/order', { method: 'PUT', body: { attributenameIds } });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
38
src/api/attributevalue.js
Normal file
38
src/api/attributevalue.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const attributevalue = {
|
||||
|
||||
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
|
||||
// #region POST /api/attributename/:attributeId/value — create
|
||||
create(attributeId, body) {
|
||||
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PATCH /api/attributevalue/:id → 200 { attributevalue } */
|
||||
// #region PATCH /api/attributevalue/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/attributevalue/:id → 200 */
|
||||
// #region DELETE /api/attributevalue/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PUT /api/attributename/:attributeId/value/order → 200 */
|
||||
// #region PUT /api/attributename/:attributeId/value/order — order
|
||||
order(attributeId, attributevalueIds) {
|
||||
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value/order`, {
|
||||
method: 'PUT',
|
||||
body: { attributevalueIds },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
64
src/api/auth.js
Normal file
64
src/api/auth.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { http, logApi } from './http.js';
|
||||
|
||||
export const auth = {
|
||||
// #region GET /api/auth/me — me
|
||||
async me() {
|
||||
try {
|
||||
const data = await http('/api/auth/me');
|
||||
return {
|
||||
user: data.user ?? null,
|
||||
googleClientId: data.googleClientId ?? null,
|
||||
schemaVersion: data.schemaVersion ?? null,
|
||||
vhost: Boolean(data.vhost),
|
||||
vhostSettings: data.vhostSettings ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId ?? null,
|
||||
schemaVersion: err.data?.schemaVersion ?? null,
|
||||
vhost: Boolean(err.data?.vhost),
|
||||
vhostSettings: err.data?.vhostSettings ?? null,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/auth/login — loginWithGoogle
|
||||
async loginWithGoogle(idToken) {
|
||||
logApi('loginWithGoogle', {
|
||||
idTokenLength: idToken?.length ?? 0,
|
||||
idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null,
|
||||
});
|
||||
const data = await http('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { idToken },
|
||||
});
|
||||
// Confirm whether the session cookie actually works (not just login JSON).
|
||||
const session = await auth.me();
|
||||
logApi('session-after-login', {
|
||||
loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null,
|
||||
meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null,
|
||||
meForbidden: session.forbidden ?? false,
|
||||
meMessage: session.message ?? null,
|
||||
sessionOk: Boolean(session.user),
|
||||
});
|
||||
return { user: data.user, session };
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/auth/logout — logout
|
||||
async logout() {
|
||||
try {
|
||||
await http('/api/auth/logout', { method: 'POST' });
|
||||
} catch (err) {
|
||||
if (err.status !== 401) throw err;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
24
src/api/autoinvite.js
Normal file
24
src/api/autoinvite.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Pending invites for emails that are not registered yet (admin only).
|
||||
*/
|
||||
export const autoinvite = {
|
||||
// #region GET /api/autoinvite — list pending invites
|
||||
list() {
|
||||
return http('/api/autoinvite');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/autoinvite — create an invite
|
||||
create(body) {
|
||||
return http('/api/autoinvite', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/autoinvite/:id — delete an invite
|
||||
delete(id) {
|
||||
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
51
src/api/backup.js
Normal file
51
src/api/backup.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Backup API for listing backups, checking service status, inspecting archived VFS files, and recovering VFS files.
|
||||
*/
|
||||
export const backup = {
|
||||
/**
|
||||
* GET /api/backup
|
||||
* Returns: 200 { status: BackupStatus, backups: { counts, hourly, daily, monthly } }
|
||||
*/
|
||||
// #region GET /api/backup — getStatus
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
getStatus() {
|
||||
return http('/api/backup');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/backup/:id/vfs
|
||||
* Lists VFS files archived in a specific backup for a company.
|
||||
* @param {string} id Backup identifier (e.g. backup-2026-10-14_02-00-00)
|
||||
* @param {number} [companyId] Optional company ID (defaults to active company)
|
||||
* @returns {Promise<{ backupId: string, companyId: number, files: Array<{ name: string, size: number, mime: string, mtime: string }> }>}
|
||||
*/
|
||||
// #region GET /api/backup/{id}/vfs — listVfs
|
||||
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
|
||||
listVfs(id, companyId) {
|
||||
const q = companyId != null ? `?companyId=${encodeURIComponent(companyId)}` : '';
|
||||
return http(`/api/backup/${encodeURIComponent(id)}/vfs${q}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/backup/:id/vfs/recover
|
||||
* Safely extracts and restores a VFS file from the specified backup into the active VFS.
|
||||
* @param {string} id Backup identifier
|
||||
* @param {string} path Relative file path within the VFS archive
|
||||
* @param {number} [companyId] Optional company ID
|
||||
* @returns {Promise<{ success: boolean, backupId: string, companyId: number, path: string, sizeBytes: number, mime: string }>}
|
||||
*/
|
||||
// #region POST /api/backup/{id}/vfs/recover — recoverVfs
|
||||
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
|
||||
recoverVfs(id, path, companyId) {
|
||||
return http(`/api/backup/${encodeURIComponent(id)}/vfs/recover`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, companyId }),
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
83
src/api/brand.js
Normal file
83
src/api/brand.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { http, httpBinary } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped brands (selectedCompanyId from session).
|
||||
* Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] }
|
||||
*/
|
||||
export const brand = {
|
||||
/**
|
||||
* GET /api/brand → { brands: Brand[], total, offset, limit }
|
||||
* @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params]
|
||||
*/
|
||||
// #region GET /api/brand — list
|
||||
list(params) {
|
||||
const offset = Number(params?.offset ?? 0);
|
||||
const limit = Number(params?.limit ?? 0);
|
||||
const includeSmallThumbs = params?.includeSmallThumbs;
|
||||
const query = new URLSearchParams();
|
||||
if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset));
|
||||
if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit));
|
||||
if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false');
|
||||
const qs = query.toString();
|
||||
return http(qs ? `/api/brand?${qs}` : '/api/brand');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/brand
|
||||
* Body: { name, gsprpinfo? } — insert for the selected company.
|
||||
* Empty name ⇒ 400 Invalid brand.
|
||||
* → 201 { brand: Brand } · 409 Brand already exists
|
||||
*/
|
||||
// #region POST /api/brand — create
|
||||
create(body) {
|
||||
return http('/api/brand', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/brand/:id
|
||||
* Body: { name, gsprpinfo? } — rename / update info.
|
||||
* → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found
|
||||
*/
|
||||
// #region PATCH /api/brand/:id — rename
|
||||
rename(id, body) {
|
||||
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/brand/:id
|
||||
* → 204 No Content · 404 Brand not found
|
||||
*/
|
||||
// #region DELETE /api/brand/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/brand/:id/image
|
||||
* Accepts full-size image body (Content-Type: image/webp).
|
||||
* → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }
|
||||
*/
|
||||
// #region POST /api/brand/:id/image — addImage
|
||||
addImage(brandId, file) {
|
||||
return httpBinary(`/api/brand/${brandId}/image`, {
|
||||
method: 'POST',
|
||||
contentType: file?.type || 'image/webp',
|
||||
body: file,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/brand/:id/image
|
||||
* → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } }
|
||||
*/
|
||||
// #region DELETE /api/brand/:id/image — deleteImage
|
||||
deleteImage(brandId) {
|
||||
return http(`/api/brand/${brandId}/image`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
49
src/api/carrier.js
Normal file
49
src/api/carrier.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped carriers / Versandarten.
|
||||
* Shape: Carrier = { id, name, sort, hasPackages, isReverse }
|
||||
*/
|
||||
export const carrier = {
|
||||
/** GET /api/carrier → { carriers: Carrier[] } */
|
||||
list() {
|
||||
return http('/api/carrier');
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/carrier
|
||||
* Body: { name, sort?, hasPackages?, isReverse? }
|
||||
* → 201 { carrier: Carrier }
|
||||
*/
|
||||
create(body) {
|
||||
return http('/api/carrier', { method: 'POST', body });
|
||||
},
|
||||
|
||||
/** GET /api/carrier/:id → 200 { carrier: Carrier } */
|
||||
get(id) {
|
||||
return http(`/api/carrier/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/carrier/:id
|
||||
* Body: { name?, sort?, hasPackages?, isReverse? }
|
||||
* → 200 { carrier: Carrier }
|
||||
*/
|
||||
update(id, body) {
|
||||
return http(`/api/carrier/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/carrier/order
|
||||
* Body: { carrierIds: number[] }
|
||||
* → 200 { carrierIds: number[] }
|
||||
*/
|
||||
order(body) {
|
||||
return http('/api/carrier/order', { method: 'PUT', body });
|
||||
},
|
||||
|
||||
/** DELETE /api/carrier/:id → 204 · 409 Conflict if in use */
|
||||
delete(id) {
|
||||
return http(`/api/carrier/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
155
src/api/category.js
Normal file
155
src/api/category.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { http, httpBinary, httpGetBinary } from './http.js';
|
||||
|
||||
export const category = {
|
||||
/** GET /api/category/tree → { languageId, tree, articleCount } (company from session) */
|
||||
// #region GET /api/category/tree — getTree
|
||||
getTree() {
|
||||
return http('/api/category/tree');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/category/:id/articles → { articles: [...] }
|
||||
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, childCount, …
|
||||
*/
|
||||
// #region GET /api/category/:id/articles — getArticles
|
||||
getArticles(categoryId) {
|
||||
return http(`/api/category/${categoryId}/articles`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/category → 201 { category } */
|
||||
// #region POST /api/category — create
|
||||
create({ parentId, name }) {
|
||||
return http('/api/category', {
|
||||
method: 'POST',
|
||||
body: { parentId: Number(parentId), name },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/category/:id → 204
|
||||
* Query: ?recursive=true (cascades deletion of subcategories and articles)
|
||||
*/
|
||||
// #region DELETE /api/category/:id — delete
|
||||
delete(categoryId, { recursive = false } = {}) {
|
||||
const qs = recursive ? '?recursive=true' : '';
|
||||
return http(`/api/category/${categoryId}${qs}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/category/:id
|
||||
* Body: { name } — renames and re-slugifies in the company default language.
|
||||
* Empty name → 400 name is required.
|
||||
* → 200 { category: { id, name, slug } }
|
||||
*/
|
||||
// #region PATCH /api/category/:id — updateName
|
||||
updateName(categoryId, name) {
|
||||
return http(`/api/category/${categoryId}`, {
|
||||
method: 'PATCH',
|
||||
body: { name },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/category/order
|
||||
* Body: { parentId, categoryIds: number[] } — sibling order of the children
|
||||
* under `parentId` (top-down). categoryIds must be exactly the current child
|
||||
* set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order.
|
||||
* → 200 { categoryIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/category/order — reorder
|
||||
reorder(parentId, categoryIds) {
|
||||
return http('/api/category/order', {
|
||||
method: 'PUT',
|
||||
body: { parentId, categoryIds },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/category/:id
|
||||
* Body: { parentId } — moves the category under a new parent.
|
||||
* → 200 { category: { id, parentId, sort } } (kein name/slug im Response)
|
||||
*/
|
||||
// #region PATCH /api/category/:id — move
|
||||
move(categoryId, parentId) {
|
||||
return http(`/api/category/${categoryId}`, {
|
||||
method: 'PATCH',
|
||||
body: { parentId },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/category/:id
|
||||
* Body: { pos: boolean } — toggles the POS (Point of Sale) flag.
|
||||
* → 200 { category: { id, pos } }
|
||||
*
|
||||
* PROPOSED — backend needs to implement this field.
|
||||
*/
|
||||
// #region PATCH /api/category/:id — updatePos
|
||||
updatePos(categoryId, pos) {
|
||||
return http(`/api/category/${categoryId}`, {
|
||||
method: 'PATCH',
|
||||
body: { pos },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/category/:id/image
|
||||
* Body: raw image bytes (Content-Type: image/webp)
|
||||
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
|
||||
*/
|
||||
// #region POST /api/category/:id/image — uploadImage
|
||||
uploadImage(categoryId, file) {
|
||||
return httpBinary(`/api/category/${categoryId}/image`, {
|
||||
method: 'POST',
|
||||
contentType: file?.type || 'image/webp',
|
||||
body: file,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/category/:id/image
|
||||
* → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
|
||||
*/
|
||||
// #region DELETE /api/category/:id/image — deleteImage
|
||||
deleteImage(categoryId) {
|
||||
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/category/:id/image/generate
|
||||
* Body (optional): { size?: 2 | 3 | 4 }
|
||||
* → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } }
|
||||
*/
|
||||
// #region POST /api/category/:id/image/generate — generateImage
|
||||
generateImage(categoryId, { size } = {}) {
|
||||
return http(`/api/category/${categoryId}/image/generate`, {
|
||||
method: 'POST',
|
||||
body: size != null ? { size } : undefined,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/category/export.zip → application/zip (binary download)
|
||||
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
|
||||
* depth-first, `Ebene N` name column per depth) + one `<fullHash>.avif`
|
||||
* entry per category image (content-addressed, deduplicated).
|
||||
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||
*/
|
||||
// #region GET /api/category/export.zip — exportZip
|
||||
exportZip() {
|
||||
return httpGetBinary('/api/category/export.zip');
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
336
src/api/company.js
Normal file
336
src/api/company.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const company = {
|
||||
/** GET /api/company → { companies: [...] } */
|
||||
// #region GET /api/company — list
|
||||
list() {
|
||||
return http('/api/company');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** Uses GET /api/company until a dedicated endpoint exists. */
|
||||
// #region GET /api/company — getById
|
||||
async getById(companyId) {
|
||||
const data = await http('/api/company');
|
||||
const found = data.companies?.find((item) => item.id === companyId);
|
||||
if (!found) throw new Error('Unternehmen nicht gefunden.');
|
||||
return { company: found };
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/company — create
|
||||
create({ name, iso639, taxrate, reducedtaxrate }) {
|
||||
return http('/api/company', {
|
||||
method: 'POST',
|
||||
body: { name, iso639, taxrate, reducedtaxrate },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/company/:companyId
|
||||
* Body: { name, iso639, taxrate, reducedtaxrate }
|
||||
*/
|
||||
// #region PUT /api/company/:companyId — update
|
||||
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
|
||||
return http(`/api/company/${companyId}`, {
|
||||
method: 'PUT',
|
||||
body: { name, iso639, taxrate, reducedtaxrate },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PUT /api/auth/selected-company — select
|
||||
select(companyId) {
|
||||
return http('/api/auth/selected-company', {
|
||||
method: 'PUT',
|
||||
body: { companyId },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/company/import
|
||||
* Uploads and streams catalog import (.zip) archive via Server-Sent Events.
|
||||
* Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time.
|
||||
*
|
||||
* @param {File|Blob|null} file
|
||||
* @param {object} [callbacks]
|
||||
* @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress]
|
||||
* @param {(message: string) => void} [callbacks.onStatus]
|
||||
* @param {(progress: { categoriestotal: number, categoriesdone: number, articlestotal: number, articlesdone: number, imagestotal: number, imagesdone: number, receiptstotal: number, receiptsdone: number }) => void} [callbacks.onProgress] Counter-based progress snapshot across all phases.
|
||||
* @param {(images: string[]) => void} [callbacks.onImages] Batched `images` events (~every 100ms) carrying Base62 SkeleHash strings.
|
||||
* @param {(stats: object) => void} [callbacks.onDone]
|
||||
* @param {(error: string) => void} [callbacks.onError]
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal]
|
||||
*/
|
||||
// #region POST /api/company/import — importCatalog
|
||||
importCatalog(file, callbacks = {}, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/company/import');
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Accept', 'text/event-stream');
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
return reject(new DOMException('Aborted', 'AbortError'));
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
}
|
||||
|
||||
// Track request body upload progress
|
||||
if (file && xhr.upload) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
|
||||
callbacks.onUploadProgress?.({
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
percent,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Stream and parse incoming SSE chunks as the server processes the archive
|
||||
let processedIndex = 0;
|
||||
let buffer = '';
|
||||
|
||||
const processChunks = () => {
|
||||
const text = xhr.responseText || '';
|
||||
if (text.length <= processedIndex) return;
|
||||
const newChunk = text.slice(processedIndex);
|
||||
processedIndex = text.length;
|
||||
|
||||
buffer += newChunk;
|
||||
const messages = buffer.split('\n\n');
|
||||
buffer = messages.pop() || '';
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
|
||||
const eventMatch = msg.match(/^event:\s*(\w+)/m);
|
||||
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
||||
|
||||
const eventType = eventMatch ? eventMatch[1] : 'message';
|
||||
let eventData = null;
|
||||
if (dataMatch) {
|
||||
try {
|
||||
eventData = JSON.parse(dataMatch[1]);
|
||||
} catch {
|
||||
eventData = dataMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
switch (eventType) {
|
||||
case 'status':
|
||||
callbacks.onStatus?.(eventData?.message);
|
||||
break;
|
||||
case 'progress':
|
||||
callbacks.onProgress?.(eventData);
|
||||
break;
|
||||
case 'images': {
|
||||
// Payload is a plain array of Base62 SkeleHash strings;
|
||||
// also tolerate the { images: [...] } wrapper shape.
|
||||
const list = Array.isArray(eventData)
|
||||
? eventData
|
||||
: Array.isArray(eventData?.images)
|
||||
? eventData.images
|
||||
: [];
|
||||
if (list.length) callbacks.onImages?.(list);
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
// Legacy: single image event (pre-batching).
|
||||
if (eventData) callbacks.onImages?.([eventData]);
|
||||
break;
|
||||
case 'done':
|
||||
callbacks.onDone?.(eventData?.stats ?? eventData);
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(eventData?.message || 'Fehler beim Importieren');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onprogress = processChunks;
|
||||
|
||||
xhr.onload = () => {
|
||||
processChunks();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`;
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText);
|
||||
errMsg = parsed.error || parsed.message || errMsg;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(new Error(errMsg));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('Netzwerkfehler beim Upload der Datei'));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
if (file) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/company/export
|
||||
* Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks.
|
||||
*
|
||||
* @param {object} [callbacks]
|
||||
* @param {(message: string) => void} [callbacks.onStatus]
|
||||
* @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress]
|
||||
* @param {(chunk: string) => void} [callbacks.onChunk]
|
||||
* @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone]
|
||||
* @param {(error: string) => void} [callbacks.onError]
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal]
|
||||
* @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete
|
||||
* @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>}
|
||||
*/
|
||||
// #region GET /api/company/export — exportCatalog
|
||||
async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) {
|
||||
const response = await fetch('/api/company/export', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
credentials: 'include',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errMessage = 'Export failed';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errMessage = error.error || error.message || errMessage;
|
||||
} catch {
|
||||
// ignore JSON parse error
|
||||
}
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const base64Chunks = [];
|
||||
let filename = 'company-export.zip';
|
||||
let donePayload = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? ''; // keep trailing incomplete message
|
||||
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n');
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||
}
|
||||
|
||||
if (event === 'status') {
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
callbacks.onStatus?.(payload.message || data);
|
||||
} catch {
|
||||
callbacks.onStatus?.(data);
|
||||
}
|
||||
} else if (event === 'progress') {
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
callbacks.onProgress?.(payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (event === 'chunk') {
|
||||
base64Chunks.push(data);
|
||||
callbacks.onChunk?.(data);
|
||||
} else if (event === 'done') {
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
donePayload = payload;
|
||||
if (payload.filename) filename = payload.filename;
|
||||
callbacks.onDone?.(payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (event === 'error') {
|
||||
let errorMsg = 'Export error';
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
errorMsg = payload.message || errorMsg;
|
||||
} catch {
|
||||
errorMsg = data || errorMsg;
|
||||
}
|
||||
callbacks.onError?.(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Reconstruct Blob from base64 chunks
|
||||
const byteArrays = base64Chunks.map((b64) => {
|
||||
const binaryStr = atob(b64);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
});
|
||||
|
||||
const blob = new Blob(byteArrays, { type: 'application/zip' });
|
||||
|
||||
if (triggerDownload) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return {
|
||||
blob,
|
||||
filename,
|
||||
size: blob.size,
|
||||
totalChunks: base64Chunks.length,
|
||||
stats: donePayload?.stats,
|
||||
};
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);
|
||||
|
||||
30
src/api/companyuser.js
Normal file
30
src/api/companyuser.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company members (admin only).
|
||||
*/
|
||||
export const companyuser = {
|
||||
// #region GET /api/companyuser — list members
|
||||
list() {
|
||||
return http('/api/companyuser');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/companyuser — add an existing user by email
|
||||
create(body) {
|
||||
return http('/api/companyuser', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PATCH /api/companyuser/:id — change role
|
||||
setRole(id, role) {
|
||||
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/companyuser/:id — remove a member
|
||||
delete(id) {
|
||||
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
58
src/api/customergroup.js
Normal file
58
src/api/customergroup.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped customer groups.
|
||||
* Shape: CustomerGroup = { id, name, sort }
|
||||
* List item: CustomerGroup & { editable } — editable false for POS / Lieferanten
|
||||
* (name locked). Reorder via PUT /api/customergroup/order.
|
||||
*/
|
||||
export const customergroup = {
|
||||
/** GET /api/customergroup → { customergroups: (CustomerGroup & { editable })[] } ordered by sort */
|
||||
// #region GET /api/customergroup — list
|
||||
list() {
|
||||
return http('/api/customergroup');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/customergroup
|
||||
* Body: { name } — empty name ⇒ 400 Name required;
|
||||
* reserved POS / Lieferanten ⇒ 400 Name not allowed; sort appends next index.
|
||||
* → 201 { customergroup: CustomerGroup }
|
||||
*/
|
||||
// #region POST /api/customergroup — create
|
||||
create(body) {
|
||||
return http('/api/customergroup', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/customergroup/:id
|
||||
* Body: { name } — locked groups cannot be renamed (Name cannot be changed).
|
||||
* → 200 { customergroup: CustomerGroup }
|
||||
*/
|
||||
// #region PATCH /api/customergroup/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/customergroup/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/customergroup/order
|
||||
* Body: { customergroupIds: number[] } — must be exactly the company set (same ids, no dups)
|
||||
* else 400 Invalid customer group order. Sets sort to each id’s index 0…n-1.
|
||||
* → 200 { customergroupIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/customergroup/order — order
|
||||
order(body) {
|
||||
return http('/api/customergroup/order', { method: 'PUT', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */
|
||||
// #region DELETE /api/customergroup/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/customergroup/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
132
src/api/delivery.js
Normal file
132
src/api/delivery.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { http, clientIdHeaders } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped goods receipts (manifest type = delivery).
|
||||
* DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date }
|
||||
* - number: server-allocated, read-only manifest number (from nextNumber('delivery')).
|
||||
* - externalNumber: user-entered reference (nullable).
|
||||
* DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment }
|
||||
* Delivery = DeliverySummary & { lines: DeliveryLine[] }
|
||||
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
||||
*/
|
||||
export const delivery = {
|
||||
/**
|
||||
* GET /api/delivery
|
||||
* @param {{ offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number }} [opts]
|
||||
* → { deliveries: DeliverySummary[], total?: number, unfilteredTotal?: number, offset?: number, limit?: number }
|
||||
*/
|
||||
// #region GET /api/delivery — list
|
||||
list(opts) {
|
||||
if (opts && typeof opts === 'object') {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(opts.offset) || 0))));
|
||||
if (opts.limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(opts.limit) || 50))));
|
||||
if (opts.search && String(opts.search).trim()) params.set('search', String(opts.search).trim());
|
||||
if (opts.onlyUnbooked === true || opts.onlyUnbooked === 'true') params.set('onlyUnbooked', 'true');
|
||||
if (opts.supplierId != null) params.set('supplierId', String(opts.supplierId));
|
||||
if (opts.seekMonths != null && Number(opts.seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(opts.seekMonths))));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return http(`/api/delivery${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
return http('/api/delivery');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/delivery/rebuild-summaries → { ok: boolean, companyId: number } */
|
||||
rebuildSummaries() {
|
||||
return http('/api/delivery/rebuild-summaries', { method: 'POST' });
|
||||
},
|
||||
|
||||
/** GET /api/delivery/:id → { delivery: Delivery } */
|
||||
// #region GET /api/delivery/:id — get
|
||||
get(id) {
|
||||
return http(`/api/delivery/${id}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/delivery
|
||||
* Body: { externalNumber?, date, supplierId?, lines: DeliveryLineInput[] }
|
||||
* number is NOT sent — the server allocates it automatically. The
|
||||
* caller-supplied reference goes in externalNumber (nullable).
|
||||
* Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount.
|
||||
* → 201 { delivery: Delivery }
|
||||
*/
|
||||
// #region POST /api/delivery — create
|
||||
create(body) {
|
||||
return http('/api/delivery', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/delivery/:id
|
||||
* Body: partial { externalNumber?, date?, supplierId?, lines? }.
|
||||
* number is read-only and cannot be edited. externalNumber is editable and
|
||||
* can be cleared by sending null or an empty string.
|
||||
* If lines present, replaces all lines; if omitted, lines unchanged.
|
||||
* → 200 { delivery: Delivery }
|
||||
*/
|
||||
// #region PATCH /api/delivery/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/delivery/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/delivery/:id → 204 */
|
||||
// #region DELETE /api/delivery/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/delivery/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/delivery/:id/file/:fileId → Blob
|
||||
* Full binary content of attached file (PDF, text, etc.)
|
||||
*/
|
||||
// #region GET /api/delivery/:id/file/:fileId — getFile
|
||||
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();
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/delivery/:id/file/:fileId
|
||||
* Deletes delivery file attachment.
|
||||
* @param {number|string} id Delivery ID
|
||||
* @param {number|string} fileId File ID
|
||||
* → 204 void
|
||||
*/
|
||||
// #region DELETE /api/delivery/:id/file/:fileId — deleteFile
|
||||
deleteFile(id, fileId) {
|
||||
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/delivery/:id/pdf
|
||||
* Generates the "Wareneingang" PDF and stores it as a manifest attachment.
|
||||
* → 201 { attachment, fileUrl }
|
||||
*/
|
||||
// #region POST /api/delivery/:id/pdf — createPdf
|
||||
createPdf(id) {
|
||||
return http(`/api/delivery/${id}/pdf`, { method: 'POST' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
126
src/api/entity.js
Normal file
126
src/api/entity.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped entities (customers / contacts in a customergroup).
|
||||
* Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt }
|
||||
*/
|
||||
export const entity = {
|
||||
/**
|
||||
* GET /api/entity → { entities: Entity[] }
|
||||
* Optional query: customergroupId
|
||||
*/
|
||||
// #region GET /api/entity — list
|
||||
list({ customergroupId } = {}) {
|
||||
const qs =
|
||||
customergroupId != null
|
||||
? `?customergroupId=${encodeURIComponent(customergroupId)}`
|
||||
: '';
|
||||
return http(`/api/entity${qs}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/entity/:id → { entity: Entity } */
|
||||
// #region GET /api/entity/:id — get
|
||||
get(id) {
|
||||
return http(`/api/entity/${id}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/entity
|
||||
* Body: { customergroupId, name, firstName? }
|
||||
* → 201 { entity: Entity }
|
||||
*/
|
||||
// #region POST /api/entity — create
|
||||
create(body) {
|
||||
return http('/api/entity', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/entity/:id
|
||||
* Body: { name?, firstName? }
|
||||
* → 200 { entity: Entity }
|
||||
*/
|
||||
// #region PATCH /api/entity/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/entity/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/entity/:id → 204 */
|
||||
// #region DELETE /api/entity/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/entity/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/entity/next-number → { number: string } */
|
||||
// #region GET /api/entity/next-number — nextNumber
|
||||
nextNumber() {
|
||||
return http('/api/entity/next-number');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */
|
||||
// #region GET /api/entity/:entityId/address — listAddresses
|
||||
listAddresses(entityId) {
|
||||
return http(`/api/entity/${entityId}/address`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/entity/:entityId/address → 201 { address } */
|
||||
// #region POST /api/entity/:entityId/address — createAddress
|
||||
createAddress(entityId, body) {
|
||||
return http(`/api/entity/${entityId}/address`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PATCH /api/entity/:entityId/address/:id → { address } */
|
||||
// #region PATCH /api/entity/:entityId/address/:id — updateAddress
|
||||
updateAddress(entityId, id, body) {
|
||||
return http(`/api/entity/${entityId}/address/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/entity/:entityId/address/:id → 204 */
|
||||
// #region DELETE /api/entity/:entityId/address/:id — deleteAddress
|
||||
deleteAddress(entityId, id) {
|
||||
return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */
|
||||
// #region GET /api/entity/:entityId/contact — listContacts
|
||||
listContacts(entityId) {
|
||||
return http(`/api/entity/${entityId}/contact`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/entity/:entityId/contact → 201 { contact } */
|
||||
// #region POST /api/entity/:entityId/contact — createContact
|
||||
createContact(entityId, body) {
|
||||
return http(`/api/entity/${entityId}/contact`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PATCH /api/entity/:entityId/contact/:id → { contact } */
|
||||
// #region PATCH /api/entity/:entityId/contact/:id — updateContact
|
||||
updateContact(entityId, id, body) {
|
||||
return http(`/api/entity/${entityId}/contact/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/entity/:entityId/contact/:id → 204 */
|
||||
// #region DELETE /api/entity/:entityId/contact/:id — deleteContact
|
||||
deleteContact(entityId, id) {
|
||||
return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
25
src/api/globalHistory.js
Normal file
25
src/api/globalHistory.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const globalHistory = {
|
||||
/**
|
||||
* GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* Global system/entity history with chunk paging and search/seek filtering
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||
* → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||
*/
|
||||
// #region GET /api/history — list
|
||||
list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
19
src/api/health.js
Normal file
19
src/api/health.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Health API for backend latency samples.
|
||||
*/
|
||||
export const health = {
|
||||
/**
|
||||
* GET /api/health/latency?range=3h|all
|
||||
* Returns: 200 { raw: Point[] } for '3h', { raw, avg, max } for 'all'.
|
||||
* Point = { timestamp: epoch ms, value: latency ms }, sorted oldest first.
|
||||
* @param {'3h'|'all'} [range='3h']
|
||||
*/
|
||||
// #region GET /api/health/latency — getLatency
|
||||
// MARK: calls GET /api/health/latency from ./api/index.js
|
||||
getLatency(range = '3h') {
|
||||
return http(`/api/health/latency?range=${encodeURIComponent(range)}`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
185
src/api/http.js
Normal file
185
src/api/http.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// Shared HTTP helpers for all backend communication.
|
||||
// Extracted from src/api/index.js — single source of truth for fetch plumbing.
|
||||
|
||||
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
||||
export function logApi(tag, details) {
|
||||
let text;
|
||||
try {
|
||||
text = JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
text = String(details);
|
||||
}
|
||||
console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`);
|
||||
}
|
||||
|
||||
export function readableHeaders(headers) {
|
||||
const out = {};
|
||||
headers.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||
export const CLIENT_ID_HEADER = 'X-Client-Id';
|
||||
|
||||
export function getStoredClientId() {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clientIdHeaders() {
|
||||
const cid = getStoredClientId();
|
||||
return cid ? { [CLIENT_ID_HEADER]: cid } : {};
|
||||
}
|
||||
|
||||
function isMutatingMethod(method) {
|
||||
const upper = String(method || 'GET').toUpperCase();
|
||||
return upper !== 'GET' && upper !== 'HEAD';
|
||||
}
|
||||
|
||||
export async function http(path, { method = 'GET', body } = {}) {
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
const headers = {};
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (isMutatingMethod(method)) {
|
||||
Object.assign(headers, clientIdHeaders());
|
||||
}
|
||||
const url = new URL(path, window.location.origin).href;
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
logApi('error-body', { method, path, status: res.status, message, data });
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = path;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const json = await res.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) {
|
||||
const url = new URL(path, window.location.origin).href;
|
||||
|
||||
logApi('request', {
|
||||
method,
|
||||
path,
|
||||
url,
|
||||
origin: window.location.origin,
|
||||
credentials: 'include',
|
||||
contentType,
|
||||
bodyBytes: body?.byteLength ?? body?.size ?? null,
|
||||
});
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) },
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
|
||||
const responseHeaders = readableHeaders(res.headers);
|
||||
logApi('response', {
|
||||
method,
|
||||
path,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
ok: res.ok,
|
||||
responseHeaders,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
logApi('error-body', { method, path, status: res.status, message, data });
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = path;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const json = await res.json();
|
||||
logApi('ok-body', {
|
||||
method,
|
||||
path,
|
||||
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
|
||||
hasArticle: Boolean(json?.article),
|
||||
});
|
||||
return json;
|
||||
}
|
||||
|
||||
/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */
|
||||
export async function httpGetBinary(path) {
|
||||
logApi('request', {
|
||||
method: 'GET',
|
||||
path,
|
||||
url: new URL(path, window.location.origin).href,
|
||||
origin: window.location.origin,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = await fetch(path, { method: 'GET', credentials: 'include' });
|
||||
|
||||
logApi('response', {
|
||||
method: 'GET',
|
||||
path,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
ok: res.ok,
|
||||
contentType: res.headers.get('content-type'),
|
||||
responseHeaders: readableHeaders(res.headers),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
logApi('error-body', { method: 'GET', path, status: res.status, message, data });
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = path;
|
||||
err.method = 'GET';
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
return blob;
|
||||
}
|
||||
1158
src/api/index.js
1158
src/api/index.js
File diff suppressed because it is too large
Load Diff
71
src/api/labeltemplate.js
Normal file
71
src/api/labeltemplate.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped ZPL label templates (selectedCompanyId from session).
|
||||
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
||||
*/
|
||||
export const labeltemplate = {
|
||||
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
|
||||
// #region GET /api/labeltemplate — list
|
||||
list(params) {
|
||||
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
|
||||
return http(`/api/labeltemplate${search}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/labeltemplate/:id → { labeltemplate: LabelTemplate } */
|
||||
// #region GET /api/labeltemplate/:id — get
|
||||
get(id) {
|
||||
return http(`/api/labeltemplate/${encodeURIComponent(id)}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/labeltemplate
|
||||
* Body: { name, width, height, dpi, type, content }
|
||||
* → 201 { labeltemplate: LabelTemplate }
|
||||
*/
|
||||
// #region POST /api/labeltemplate — create
|
||||
create(body) {
|
||||
return http('/api/labeltemplate', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/labeltemplate/:id
|
||||
* Body: partial { name?, width?, height?, dpi?, type?, content? }
|
||||
* → 200 { labeltemplate: LabelTemplate }
|
||||
*/
|
||||
// #region PATCH /api/labeltemplate/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/labeltemplate/:id → 204 */
|
||||
// #region DELETE /api/labeltemplate/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
|
||||
* @param {number|object} idOrBody
|
||||
* @param {object} [maybeBody]
|
||||
* → 200 { success: true, printerjob?: PrinterJob }
|
||||
*/
|
||||
// #region POST /api/labeltemplate/testprint — testprint
|
||||
testprint(idOrBody, maybeBody) {
|
||||
if (typeof idOrBody === 'object' && idOrBody !== null) {
|
||||
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
|
||||
}
|
||||
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
|
||||
method: 'POST',
|
||||
body: maybeBody || {},
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// thumb is returned inline as a base64 string in list/get responses
|
||||
};
|
||||
62
src/api/ledger.js
Normal file
62
src/api/ledger.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Append-only stock and payment ledger.
|
||||
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
|
||||
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||
*/
|
||||
export const ledger = {
|
||||
|
||||
/**
|
||||
* GET /api/ledger/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* Append-only ledger audit history with chunk paging, search, and seek filtering
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object, sku?: string, transactionType?: string }} [params]
|
||||
* → { ledger: LedgerEntry[], history?: LedgerEntry[], total: number, unfilteredTotal?: number }
|
||||
*/
|
||||
// #region GET /api/ledger/history — getHistory
|
||||
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel, sku, transactionType } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
if (sku != null) params.set('sku', String(sku));
|
||||
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/ledger/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/ledger/manifestarticle/:manifestarticleId
|
||||
* Query: ?transaction_type=...
|
||||
* → 200 { ledger: LedgerEntry[] }
|
||||
*/
|
||||
// #region GET /api/ledger/manifestarticle/:manifestarticleId — listByManifestarticleId
|
||||
listByManifestarticleId(manifestarticleId, transactionType) {
|
||||
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
|
||||
return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/ledger
|
||||
* Query: { manifestarticleId?, sku?, transactionType? }
|
||||
* → 200 { ledger: LedgerEntry[] }
|
||||
*/
|
||||
// #region GET /api/ledger — list
|
||||
list({ manifestarticleId, sku, transactionType } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
|
||||
if (sku != null) params.set('sku', String(sku));
|
||||
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||
const qs = params.toString();
|
||||
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
40
src/api/mcp.js
Normal file
40
src/api/mcp.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const mcp = {
|
||||
// #region GET /api/mcp/authorize — getAuthorize
|
||||
// MARK: calls GET /api/mcp/authorize
|
||||
async getAuthorize(searchParams) {
|
||||
const query = typeof searchParams === 'string'
|
||||
? (searchParams.startsWith('?') ? searchParams.slice(1) : searchParams)
|
||||
: new URLSearchParams(searchParams).toString();
|
||||
const url = query ? `/api/mcp/authorize?${query}` : '/api/mcp/authorize';
|
||||
return await http(url);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/mcp/authorize — postAuthorize
|
||||
// MARK: calls POST /api/mcp/authorize
|
||||
async postAuthorize(payload) {
|
||||
return await http('/api/mcp/authorize', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/mcp/authorizations — listAuthorizations
|
||||
// MARK: calls GET /api/mcp/authorizations
|
||||
async listAuthorizations() {
|
||||
return await http('/api/mcp/authorizations');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/mcp/authorizations/:clientId — revokeAuthorization
|
||||
// MARK: calls DELETE /api/mcp/authorizations/:clientId
|
||||
async revokeAuthorization(clientId) {
|
||||
return await http(`/api/mcp/authorizations/${encodeURIComponent(clientId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
63
src/api/modelconversation.js
Normal file
63
src/api/modelconversation.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const modelconversation = {
|
||||
/**
|
||||
* GET /api/modelconversation
|
||||
* Lists the user's model conversations in the selected company.
|
||||
* → 200 { conversations: [{ id, companyuser_id, provider, model, created_at, updated_at, state }] }
|
||||
*/
|
||||
// #region GET /api/modelconversation — list
|
||||
list() {
|
||||
return http('/api/modelconversation');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/modelconversation/:id
|
||||
* Fetch conversation details by ID.
|
||||
* @param {number|string} id
|
||||
*/
|
||||
// #region GET /api/modelconversation/:id — get
|
||||
get(id) {
|
||||
return http(`/api/modelconversation/${encodeURIComponent(id)}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/modelconversation
|
||||
* Creates a new conversation in state 'idle'.
|
||||
* Body: { provider, model }
|
||||
* → 201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }
|
||||
*/
|
||||
// #region POST /api/modelconversation — create
|
||||
create(body) {
|
||||
return http('/api/modelconversation', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/modelconversation/:id/message
|
||||
* Lists conversation messages, oldest first.
|
||||
* → 200 { messages: [{ id, conversation_id, created_at, role, message }] }
|
||||
*/
|
||||
// #region GET /api/modelconversation/:id/message — listMessages
|
||||
listMessages(id) {
|
||||
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/modelconversation/:id/message — addMessage
|
||||
addMessage(id, body) {
|
||||
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/modelconversation/:id
|
||||
*/
|
||||
// #region DELETE /api/modelconversation/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/modelconversation/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
24
src/api/number.js
Normal file
24
src/api/number.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const number = {
|
||||
/**
|
||||
* GET /api/number → { numbers: [{ id, type, prefix, padding, next }] }
|
||||
* Lists the number sequences for the selected company.
|
||||
*/
|
||||
// #region GET /api/number — list
|
||||
list() {
|
||||
return http('/api/number');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/number/:id
|
||||
* Body: at least one of { prefix, padding, next } — only present keys are patched.
|
||||
* → 200 { number: { id, type, prefix, padding, next } }
|
||||
*/
|
||||
// #region PATCH /api/number/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/number/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
104
src/api/order.js
Normal file
104
src/api/order.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Sales orders (manifest type = order).
|
||||
* Summary = { id, number, type: 'order', entityId, date }
|
||||
* Line shape same as quote (no transactedAmount).
|
||||
*/
|
||||
export const order = {
|
||||
/**
|
||||
* GET /api/order?offset=0&limit=50&search=...&onlyUnpaid=true&onlyUndelivered=true&seekMonths=-1
|
||||
* @param {{ offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number }} [params]
|
||||
* → { orders: Order[], total: number, offset: number, limit: number }
|
||||
*/
|
||||
// #region GET /api/order — list
|
||||
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true');
|
||||
if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true');
|
||||
if (entityId != null) params.set('entityId', String(entityId));
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return http(`/api/order${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/order/:id → { order: Order } */
|
||||
// #region GET /api/order/:id — get
|
||||
get(id) {
|
||||
return http(`/api/order/${id}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/order
|
||||
* Body: { number, date, entityId?, lines }
|
||||
* → 201 { order: Order }
|
||||
*/
|
||||
// #region POST /api/order — create
|
||||
create(body) {
|
||||
return http('/api/order', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/order/:id
|
||||
* Body: partial { number?, date?, entityId?, lines? }
|
||||
* → 200 { order: Order }
|
||||
*/
|
||||
// #region PATCH /api/order/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/order/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/order/:id → 204 */
|
||||
// #region DELETE /api/order/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/order/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/order/:id/file/:fileId
|
||||
* Deletes order file attachment.
|
||||
* @param {number|string} id Order ID
|
||||
* @param {number|string} fileId File ID
|
||||
* → 204 void
|
||||
*/
|
||||
// #region DELETE /api/order/:id/file/:fileId — deleteFile
|
||||
deleteFile(id, fileId) {
|
||||
return http(`/api/order/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/order/:id/pdf
|
||||
* Generates and attaches order confirmation PDF.
|
||||
* @param {number|string} id Order ID
|
||||
* → 201 { attachment: Attachment, fileUrl: string }
|
||||
*/
|
||||
// #region POST /api/order/:id/pdf — createPdf
|
||||
createPdf(id) {
|
||||
return http(`/api/order/${id}/pdf`, { method: 'POST' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/order/:id/shipment
|
||||
* Creates shipment from order.
|
||||
* @param {number|string} id Order ID
|
||||
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }> }
|
||||
* → 201 { shipment: Shipment }
|
||||
*/
|
||||
// #region POST /api/order/:id/shipment — createShipment
|
||||
createShipment(id, body = {}) {
|
||||
return http(`/api/order/${id}/shipment`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
33
src/api/payment.js
Normal file
33
src/api/payment.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Payments linked to orders (manifests).
|
||||
*/
|
||||
export const payment = {
|
||||
// #region GET /api/order/:id/payment — listByOrder
|
||||
listByOrder(orderId) {
|
||||
return http(`/api/order/${orderId}/payment`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/order/:id/payment — create
|
||||
create(orderIdOrBody, maybeBody) {
|
||||
if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
|
||||
return http('/api/payment', { method: 'POST', body: orderIdOrBody });
|
||||
}
|
||||
return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/payment — register
|
||||
register(body) {
|
||||
return http('/api/payment', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/payment/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/payment/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
112
src/api/posterminal.js
Normal file
112
src/api/posterminal.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const posterminal = {
|
||||
/**
|
||||
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
|
||||
* List all terminals for the selected company, sorted by sort ASC, id ASC.
|
||||
*/
|
||||
// #region GET /api/posterminal — list
|
||||
list() {
|
||||
return http('/api/posterminal');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/posterminal
|
||||
* Body: { name: string, sort?: number, enabled?: boolean, rootCategoryId?: number|null, syncRootArticles?: boolean }
|
||||
* Creates a new terminal (auto-generates 6-digit pin).
|
||||
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||
*/
|
||||
// #region PUT /api/posterminal — create
|
||||
create({ name, sort, enabled, rootCategoryId, syncRootArticles } = {}) {
|
||||
const body = { name };
|
||||
if (sort !== undefined) body.sort = Number(sort);
|
||||
if (enabled !== undefined) body.enabled = Boolean(enabled);
|
||||
if (rootCategoryId !== undefined) body.rootCategoryId = rootCategoryId != null ? Number(rootCategoryId) : null;
|
||||
if (syncRootArticles !== undefined) body.syncRootArticles = Boolean(syncRootArticles);
|
||||
return http('/api/posterminal', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/posterminal/:id
|
||||
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
|
||||
* Update posterminal configuration.
|
||||
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||
*/
|
||||
// #region PATCH /api/posterminal/:id — update
|
||||
update(terminalId, patch) {
|
||||
return http(`/api/posterminal/${terminalId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/posterminal/:id → 204
|
||||
* Deletes a terminal.
|
||||
*/
|
||||
// #region DELETE /api/posterminal/:id — delete
|
||||
delete(terminalId) {
|
||||
return http(`/api/posterminal/${terminalId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/posterminal/:id/category/:categoryId
|
||||
* Enable a category on a terminal.
|
||||
* → 200 { id: number, categoryIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/posterminal/:id/category/:categoryId — enableCategory
|
||||
enableCategory(terminalId, categoryId) {
|
||||
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/posterminal/:id/category/:categoryId
|
||||
* Disable a category on a terminal.
|
||||
* → 200 { id: number, categoryIds: number[] }
|
||||
*/
|
||||
// #region DELETE /api/posterminal/:id/category/:categoryId — disableCategory
|
||||
disableCategory(terminalId, categoryId) {
|
||||
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/posterminal/:id/all
|
||||
* Enable all categories on a terminal.
|
||||
* → 200 { id: number, categoryIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/posterminal/:id/all — enableAllCategories
|
||||
enableAllCategories(terminalId) {
|
||||
return http(`/api/posterminal/${terminalId}/all`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/posterminal/:id/all
|
||||
* Disable all categories on a terminal.
|
||||
* → 200 { id: number, categoryIds: number[] }
|
||||
*/
|
||||
// #region DELETE /api/posterminal/:id/all — disableAllCategories
|
||||
disableAllCategories(terminalId) {
|
||||
return http(`/api/posterminal/${terminalId}/all`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
79
src/api/printer.js
Normal file
79
src/api/printer.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped printers (selectedCompanyId from session).
|
||||
* Shape: Printer = { id, address, type, model, serial, sort, desc }
|
||||
*/
|
||||
export const printer = {
|
||||
/** GET /api/printer → { printers: Printer[] } */
|
||||
// #region GET /api/printer — list
|
||||
list() {
|
||||
return http('/api/printer');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/printer
|
||||
* Body: { address, type, model, serial, sort, desc }
|
||||
* → 201 { printer: Printer }
|
||||
*/
|
||||
// #region POST /api/printer — create
|
||||
create(body) {
|
||||
return http('/api/printer', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/printer/:id
|
||||
* Body: partial printer fields
|
||||
* → 200 { printer: Printer }
|
||||
*/
|
||||
// #region PATCH /api/printer/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/printer/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/printer/:id → 204 */
|
||||
// #region DELETE /api/printer/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/printer/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/printer/order
|
||||
* Body: { printerIds: number[] }
|
||||
* → 200 { printerIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/printer/order — order
|
||||
order({ printerIds }) {
|
||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/printer/state
|
||||
* Query: ?serial=, ?agentId=
|
||||
* → { printers: LivePrinter[] }
|
||||
*/
|
||||
// #region GET /api/printer/state — live state
|
||||
state({ serial, agentId } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (serial) params.set('serial', serial);
|
||||
if (agentId != null) params.set('agentId', agentId);
|
||||
const query = params.toString();
|
||||
return http(`/api/printer/state${query ? `?${query}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/printer/:id/state
|
||||
* → { printer: LivePrinter }
|
||||
*/
|
||||
// #region GET /api/printer/:id/state — single printer state
|
||||
printerState(id) {
|
||||
return http(`/api/printer/${id}/state`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
58
src/api/printeragent.js
Normal file
58
src/api/printeragent.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const printeragent = {
|
||||
/**
|
||||
* GET /api/printeragent → { printeragents: Printeragent[] }
|
||||
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
|
||||
*/
|
||||
list() {
|
||||
return http('/api/printeragent');
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/printeragent
|
||||
* Body: { name: string, sort?: number }
|
||||
* Creates a new printer agent (auto-generates 6-digit pairingpin).
|
||||
* → 200 { printeragent: Printeragent }
|
||||
*/
|
||||
create({ name, sort } = {}) {
|
||||
const body = { name };
|
||||
if (sort !== undefined && sort !== null && sort !== '') {
|
||||
body.sort = Number(sort);
|
||||
}
|
||||
return http('/api/printeragent', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/printeragent/:id
|
||||
* Body: { name?: string, sort?: number, enabled?: boolean }
|
||||
* Updates name, sort, enabled, or any combination.
|
||||
* → 200 { printeragent: Printeragent }
|
||||
*/
|
||||
update(id, patch = {}) {
|
||||
const body = {};
|
||||
if (patch.name !== undefined) body.name = patch.name;
|
||||
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||
body.sort = Number(patch.sort);
|
||||
}
|
||||
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||
return http(`/api/printeragent/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/printeragent/:id → 204 No Content
|
||||
* Permanently deletes agent.
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/printeragent/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
142
src/api/printerjob.js
Normal file
142
src/api/printerjob.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const printerjob = {
|
||||
/**
|
||||
* POST /api/zpljob
|
||||
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
createZplJob({ agentprinterId, data, copiescount = 1 }) {
|
||||
return http('/api/zpljob', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
agentprinterId: Number(agentprinterId),
|
||||
data: String(data),
|
||||
copiescount: Number(copiescount) || 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends a minimal ZPL test label to a specific agent printer.
|
||||
* @param {number} agentprinterId
|
||||
* @param {{ model?: string }} [options]
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
|
||||
const timeStr = new Date().toLocaleTimeString('de-DE');
|
||||
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
|
||||
const zpl = [
|
||||
'^XA',
|
||||
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
|
||||
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
|
||||
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
|
||||
'^XZ',
|
||||
].join('\n');
|
||||
return this.createZplJob({
|
||||
agentprinterId,
|
||||
data: zpl,
|
||||
copiescount: 1,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/printerjob
|
||||
* @param {{ agentprinterId?: number, state?: string }} [query]
|
||||
* → { printerjobs: PrinterJob[] }
|
||||
*/
|
||||
list(query = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
|
||||
if (query.state != null) params.set('state', String(query.state));
|
||||
const qs = params.toString();
|
||||
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/printerjob/:id
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
get(id) {
|
||||
return http(`/api/printerjob/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob
|
||||
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
create({ agentprinterId, type = 'zpl', data }) {
|
||||
return http('/api/printerjob', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
agentprinterId: Number(agentprinterId),
|
||||
type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob/:id/cancel
|
||||
* → { success: true, printerjob: PrinterJob }
|
||||
*/
|
||||
cancel(id) {
|
||||
return http(`/api/printerjob/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob/cancel-all
|
||||
* Sends ~JA to clear printer buffer and cancels all queued jobs.
|
||||
* @param {{ agentprinterId?: number }} [payload]
|
||||
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
|
||||
*/
|
||||
async cancelAll({ agentprinterId } = {}) {
|
||||
if (agentprinterId != null) {
|
||||
try {
|
||||
await this.createZplJob({
|
||||
agentprinterId: Number(agentprinterId),
|
||||
data: '~JA',
|
||||
copiescount: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to send ~JA to printer:', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await http('/api/printerjob/cancel-all', {
|
||||
method: 'POST',
|
||||
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.status === 404) {
|
||||
const { printerjobs = [] } = await this.list({ agentprinterId });
|
||||
const cancellable = printerjobs.filter(
|
||||
(j) => j.state === 'pending' || j.state === 'processing',
|
||||
);
|
||||
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
|
||||
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
|
||||
return {
|
||||
success: true,
|
||||
cancelledCount: results.length,
|
||||
jobs: results.map((r) => r.printerjob),
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/printerjob/:id
|
||||
* → { success: true, deletedJob: PrinterJob }
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/printerjob/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
38
src/api/propertyname.js
Normal file
38
src/api/propertyname.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const propertyname = {
|
||||
/** GET /api/propertyname → { propertynames: Propertyname[] } */
|
||||
// #region GET /api/propertyname — list
|
||||
list() {
|
||||
return http('/api/propertyname');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** POST /api/propertyname → 201 { propertyname } */
|
||||
// #region POST /api/propertyname — create
|
||||
create(body) {
|
||||
return http('/api/propertyname', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PATCH /api/propertyname/:id → 200 { propertyname } */
|
||||
// #region PATCH /api/propertyname/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/propertyname/:id → 200 */
|
||||
// #region DELETE /api/propertyname/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** PUT /api/propertyname/order → 200 */
|
||||
// #region PUT /api/propertyname/order — order
|
||||
order(propertynameIds) {
|
||||
return http('/api/propertyname/order', { method: 'PUT', body: { propertynameIds } });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
104
src/api/quote.js
Normal file
104
src/api/quote.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Sales quotes (manifest type = quote).
|
||||
* Summary = { id, number, type: 'quote', entityId, date }
|
||||
* Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount
|
||||
* LineInput = { sku, amount, net, tax, rebate?, comment? }
|
||||
*/
|
||||
export const quote = {
|
||||
/**
|
||||
* GET /api/quote?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
|
||||
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
|
||||
*/
|
||||
// #region GET /api/quote — list
|
||||
list({ offset = 0, limit = 50, search, seekMonths, entityId } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (entityId != null) params.set('entityId', String(entityId));
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return http(`/api/quote${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/quote/:id → { quote: Quote } */
|
||||
// #region GET /api/quote/:id — get
|
||||
get(id) {
|
||||
return http(`/api/quote/${id}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/quote
|
||||
* Body: { number, date, entityId?, lines }
|
||||
* → 201 { quote: Quote }
|
||||
*/
|
||||
// #region POST /api/quote — create
|
||||
create(body) {
|
||||
return http('/api/quote', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/quote/:id
|
||||
* Body: partial { number?, date?, entityId?, lines? }
|
||||
* → 200 { quote: Quote }
|
||||
*/
|
||||
// #region PATCH /api/quote/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/quote/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/quote/:id → 204 */
|
||||
// #region DELETE /api/quote/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/quote/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/quote/:id/pdf
|
||||
* Generates and attaches quote PDF.
|
||||
* @param {number|string} id Quote ID
|
||||
* → 201 { attachment: object, fileUrl: string }
|
||||
*/
|
||||
// #region POST /api/quote/:id/pdf — createPdf
|
||||
createPdf(id) {
|
||||
return http(`/api/quote/${id}/pdf`, { method: 'POST' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/quote/:id/file/:fileId
|
||||
* Deletes quote attachment file.
|
||||
* @param {number|string} id Quote ID
|
||||
* @param {number|string} fileId File ID
|
||||
* → 204 void
|
||||
*/
|
||||
// #region DELETE /api/quote/:id/file/:fileId — deleteFile
|
||||
deleteFile(id, fileId) {
|
||||
return http(`/api/quote/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/quote/:id/order
|
||||
* Converts quote into sales order.
|
||||
* @param {number|string} id Quote ID
|
||||
* @param {object} [body] Optional overrides: { number, date, entityId, externalNumber, lines }
|
||||
* → 201 { order: Order }
|
||||
*/
|
||||
// #region POST /api/quote/:id/order — createOrder
|
||||
createOrder(id, body = {}) {
|
||||
return http(`/api/quote/${id}/order`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
187
src/api/retoure.js
Normal file
187
src/api/retoure.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
|
||||
* Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
|
||||
* positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
|
||||
*/
|
||||
export function normalizeRetoureSummary(row) {
|
||||
if (!row) return null;
|
||||
const id = row.manifest_id ?? row.id;
|
||||
const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
|
||||
const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
|
||||
const entityId = row.entity_id ?? row.entityId ?? null;
|
||||
const customerName = row.customer_name ?? row.customerName ?? '';
|
||||
const positions = Number(row.positions) || 0;
|
||||
const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
|
||||
const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
|
||||
const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
|
||||
const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
|
||||
const isPaid = Boolean(row.is_paid ?? row.isPaid);
|
||||
const isShipped = Boolean(row.is_shipped ?? row.isShipped);
|
||||
const createdAt = row.created_at ?? row.createdAt ?? null;
|
||||
|
||||
return {
|
||||
id,
|
||||
manifest_id: id,
|
||||
number: num,
|
||||
retoure_number: num,
|
||||
date: dt,
|
||||
retoure_date: dt,
|
||||
entityId,
|
||||
entity_id: entityId,
|
||||
customerName,
|
||||
customer_name: customerName,
|
||||
positions,
|
||||
grossTotal,
|
||||
gross_total: grossTotal,
|
||||
paidTotal,
|
||||
paid_total: paidTotal,
|
||||
orderedQuantity,
|
||||
ordered_quantity: orderedQuantity,
|
||||
shippedQuantity,
|
||||
shipped_quantity: shippedQuantity,
|
||||
isPaid,
|
||||
is_paid: isPaid,
|
||||
isShipped,
|
||||
is_shipped: isShipped,
|
||||
createdAt,
|
||||
created_at: createdAt,
|
||||
lines: row.lines ?? [],
|
||||
files: row.files ?? [],
|
||||
shipments: row.shipments ?? [],
|
||||
payments: row.payments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Returns / Retouren (manifest type = retoure).
|
||||
*/
|
||||
export const retoure = {
|
||||
/**
|
||||
* GET /api/retoure
|
||||
* Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
|
||||
* → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
|
||||
*/
|
||||
// #region GET /api/retoure — list
|
||||
async list({
|
||||
q,
|
||||
search,
|
||||
from,
|
||||
to,
|
||||
isPaid,
|
||||
isShipped,
|
||||
onlyUnpaid,
|
||||
onlyUndelivered,
|
||||
page,
|
||||
pageSize,
|
||||
offset,
|
||||
limit,
|
||||
entityId,
|
||||
seekMonths,
|
||||
} = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const query = q || search;
|
||||
if (query && String(query).trim()) {
|
||||
params.set('q', String(query).trim());
|
||||
params.set('search', String(query).trim());
|
||||
}
|
||||
if (from) params.set('from', String(from));
|
||||
if (to) params.set('to', String(to));
|
||||
if (isPaid !== undefined && isPaid !== null) {
|
||||
params.set('isPaid', String(isPaid));
|
||||
} else if (onlyUnpaid === true || onlyUnpaid === 'true') {
|
||||
params.set('isPaid', 'false');
|
||||
}
|
||||
if (isShipped !== undefined && isShipped !== null) {
|
||||
params.set('isShipped', String(isShipped));
|
||||
} else if (onlyUndelivered === true || onlyUndelivered === 'true') {
|
||||
params.set('isShipped', 'false');
|
||||
}
|
||||
if (page != null) params.set('page', String(page));
|
||||
if (pageSize != null) params.set('pageSize', String(pageSize));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
if (entityId != null) params.set('entityId', String(entityId));
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
|
||||
const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
|
||||
const items = rawList.map(normalizeRetoureSummary);
|
||||
const total = Number(res?.total ?? res?.count ?? items.length);
|
||||
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||
|
||||
return {
|
||||
retoures: items,
|
||||
summaries: items,
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
raw: res,
|
||||
};
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/retoure/:id → { retoure: Retoure } */
|
||||
// #region GET /api/retoure/:id — get
|
||||
async get(id) {
|
||||
const res = await http(`/api/retoure/${id}`);
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/retoure
|
||||
* Body: { number?, date, entityId?, externalNumber?, lines }
|
||||
* → 201 { retoure: Retoure }
|
||||
*/
|
||||
// #region POST /api/retoure — create
|
||||
async create(body) {
|
||||
const res = await http('/api/retoure', { method: 'POST', body });
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/retoure/:id
|
||||
* Body: { number?, date?, entityId?, externalNumber?, lines? }
|
||||
* → 200 { retoure: Retoure }
|
||||
*/
|
||||
// #region PUT /api/retoure/:id — update
|
||||
async update(id, body) {
|
||||
const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/retoure/:id → 204 */
|
||||
// #region DELETE /api/retoure/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/retoure/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/retoure/:id/shipment
|
||||
* Creates return shipment.
|
||||
* Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
|
||||
* → 201 { shipment: Shipment }
|
||||
*/
|
||||
// #region POST /api/retoure/:id/shipment — createShipment
|
||||
createShipment(id, body = {}) {
|
||||
return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
142
src/api/serper.js
Normal file
142
src/api/serper.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { http, clientIdHeaders } from './http.js';
|
||||
|
||||
/**
|
||||
* Serper Google Images search and WebP thumbnail streaming.
|
||||
*/
|
||||
export const serper = {
|
||||
/**
|
||||
* POST /api/serper/images
|
||||
* Body: { q, gl, hl, num, preferSource }
|
||||
* Streams SSE events: images, thumb, done, error
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.q - Search query
|
||||
* @param {string} [params.gl='de'] - Country code
|
||||
* @param {string} [params.hl='de'] - Language code
|
||||
* @param {number} [params.num=100] - Result count (1-100)
|
||||
* @param {boolean} [params.preferSource=false] - Prefer high-res source image
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal] - Abort controller signal
|
||||
* @returns {AsyncGenerator<{event: string, data: any}>}
|
||||
*/
|
||||
async *searchImages(
|
||||
{ q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {},
|
||||
{ signal } = {},
|
||||
) {
|
||||
const res = await fetch('/api/serper/images', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...clientIdHeaders(),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ q, gl, hl, num, preferSource }),
|
||||
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 */
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/serper/image
|
||||
* Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob.
|
||||
*
|
||||
* @param {string} url - External image URL to crawl and process
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal] - Abort controller signal
|
||||
* @returns {Promise<Blob>} AVIF image blob
|
||||
*/
|
||||
async fetchImage(url, { signal } = {}) {
|
||||
const res = await fetch('/api/serper/image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...clientIdHeaders(),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ url }),
|
||||
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;
|
||||
}
|
||||
return await res.blob();
|
||||
},
|
||||
};
|
||||
38
src/api/setting.js
Normal file
38
src/api/setting.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped key/value settings (selectedCompanyId from session).
|
||||
* Shape: Setting = { key, value, frontend }
|
||||
*/
|
||||
export const setting = {
|
||||
/**
|
||||
* GET /api/company/setting
|
||||
* Optional query: { frontendOnly } → 200 { settings: Setting[] }
|
||||
*/
|
||||
// #region GET /api/company/setting — list
|
||||
list({ frontendOnly } = {}) {
|
||||
const qs = frontendOnly ? '?frontendOnly=1' : '';
|
||||
return http(`/api/company/setting${qs}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/company/setting/:key → { setting: Setting } */
|
||||
// #region GET /api/company/setting/:key — get
|
||||
get(key) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/company/setting/:key
|
||||
* Body: { value, frontend } → 200 { setting: Setting }
|
||||
*/
|
||||
// #region PUT /api/company/setting/:key — upsert
|
||||
upsert(key, body) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
70
src/api/shipment.js
Normal file
70
src/api/shipment.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Outbound shipments (manifest type = shipment).
|
||||
* Manifest number format: <orderNumber><shipmentPrefix><index>, e.g. AU-1001-L-1.
|
||||
*/
|
||||
export const shipment = {
|
||||
// #region GET /api/shipment — list
|
||||
list(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.offset) q.set('offset', String(params.offset));
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.search) q.set('search', String(params.search));
|
||||
if (params.state) q.set('state', String(params.state));
|
||||
if (params.orderId != null) q.set('orderId', String(params.orderId));
|
||||
if (params.entityId != null) q.set('entityId', String(params.entityId));
|
||||
if (params.seekMonths != null) q.set('seekMonths', String(params.seekMonths));
|
||||
const qs = q.toString();
|
||||
return http(`/api/shipment${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PATCH /api/shipment/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/shipment/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/order/:id/shipment — listByOrder
|
||||
listByOrder(orderId) {
|
||||
return http(`/api/order/${orderId}/shipment`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/order/:id/shipment — create
|
||||
create(orderId, body) {
|
||||
return http(`/api/order/${orderId}/shipment`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/shipment/:id — get
|
||||
get(id) {
|
||||
return http(`/api/shipment/${id}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/shipment/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/shipment/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/shipment/:id/file/:fileId — deleteFile
|
||||
deleteFile(id, fileId) {
|
||||
return http(`/api/shipment/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/shipment/:id/pdf — createPdf
|
||||
createPdf(id) {
|
||||
return http(`/api/shipment/${id}/pdf`, { method: 'POST' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/shipment/:id/file/:fileId — getFileUrl
|
||||
getFileUrl(id, fileId) {
|
||||
return `/api/shipment/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}`;
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
25
src/api/shipping.js
Normal file
25
src/api/shipping.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Packages / logistics shipments attached to a shipment manifest.
|
||||
*/
|
||||
export const shipping = {
|
||||
|
||||
// #region POST /api/shipment/:id/shipping — create
|
||||
create(shipmentId, body) {
|
||||
return http(`/api/shipment/${shipmentId}/shipping`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PATCH /api/shipping/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/shipping/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/shipping/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/shipping/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
36
src/api/stock.js
Normal file
36
src/api/stock.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Stock operations (manual stock adjustment).
|
||||
*/
|
||||
export const stock = {
|
||||
/**
|
||||
* POST /api/stock/adjust
|
||||
* Body: { sku, warehouseId, amount, text?, date?, externalNumber?, lines? }
|
||||
*/
|
||||
// #region POST /api/stock/adjust — adjust
|
||||
adjust(body) {
|
||||
return http('/api/stock/adjust', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/stock/accept
|
||||
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { deliveryId, warehouseId? }
|
||||
*/
|
||||
// #region POST /api/stock/accept — accept
|
||||
accept(body) {
|
||||
return http('/api/stock/accept', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/stock/ship
|
||||
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { shipmentId, warehouseId? }
|
||||
*/
|
||||
// #region POST /api/stock/ship — ship
|
||||
ship(body) {
|
||||
return http('/api/stock/ship', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createContext, createElement, useContext, useRef } from 'react';
|
||||
import { StorageManager } from './StorageManager';
|
||||
|
||||
export const StorageManagerContext = createContext(null);
|
||||
|
||||
/**
|
||||
* Provider — mount once near the top of the tree.
|
||||
*/
|
||||
export function StorageManagerProvider({ children }) {
|
||||
const mgrRef = useRef(null);
|
||||
if (!mgrRef.current) {
|
||||
mgrRef.current = new StorageManager();
|
||||
}
|
||||
|
||||
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, 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>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
68
src/api/supplier.js
Normal file
68
src/api/supplier.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
|
||||
* Shape: Supplier = { id, name, createdAt, updatedAt }
|
||||
*/
|
||||
export const supplier = {
|
||||
/** GET /api/supplier → { suppliers: Supplier[] } */
|
||||
// #region GET /api/supplier — list
|
||||
list() {
|
||||
return http('/api/supplier');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/supplier/:id → { supplier: Supplier } */
|
||||
// #region GET /api/supplier/:id — get
|
||||
get(id) {
|
||||
return http(`/api/supplier/${encodeURIComponent(id)}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/supplier
|
||||
* Body: { name } — empty name ⇒ 400 Name required.
|
||||
* → 201 { supplier: Supplier }
|
||||
*/
|
||||
// #region POST /api/supplier — create
|
||||
create(body) {
|
||||
return http('/api/supplier', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/supplier/:id
|
||||
* Body: { name } — empty name ⇒ 400 Name required.
|
||||
* → 200 { supplier: Supplier }
|
||||
*/
|
||||
// #region PATCH /api/supplier/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/supplier/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/supplier/:id → 204 */
|
||||
// #region DELETE /api/supplier/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/supplier/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/supplier/:id/articles?skus=...
|
||||
* → 200 { articles: SupplierArticle[] }
|
||||
*/
|
||||
// #region GET /api/supplier/:id/articles — getArticles
|
||||
getArticles(id, skus) {
|
||||
let query = '';
|
||||
if (skus != null) {
|
||||
const list = Array.isArray(skus) ? skus : [skus];
|
||||
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
|
||||
if (filtered.length > 0) {
|
||||
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
|
||||
}
|
||||
}
|
||||
return http(`/api/supplier/${encodeURIComponent(id)}/articles${query}`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
21
src/api/thumb.js
Normal file
21
src/api/thumb.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Content-addressed thumb AVIF by fullHash (8-char alphanumeric, see dashback pic.js).
|
||||
* Auth: session cookie. Browser-cacheable (private, immutable, ETag).
|
||||
*/
|
||||
export const thumb = {
|
||||
/**
|
||||
* GET /api/thumb/:fullHash → image/avif (thumb bytes)
|
||||
* Use as <img src={thumb.url(fullHash)} /> (same-origin; cookies sent).
|
||||
*/
|
||||
// #region GET /api/thumb/:fullHash — url
|
||||
url(fullHash) {
|
||||
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/smallthumb/:fullHash — smallUrl
|
||||
smallUrl(fullHash) {
|
||||
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
36
src/api/unit.js
Normal file
36
src/api/unit.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped units (selectedCompanyId from session).
|
||||
* Shape: { si, basesi, basesifactor, symbol, name }
|
||||
*/
|
||||
export const unit = {
|
||||
/** GET /api/unit → { units: [...] } · 400 No company selected */
|
||||
// #region GET /api/unit — list
|
||||
list() {
|
||||
return http('/api/unit');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/unit
|
||||
* Body: { si, basesi, basesifactor, symbol, name }
|
||||
* → 200 { unit }
|
||||
*/
|
||||
// #region PUT /api/unit — upsert
|
||||
upsert(body) {
|
||||
return http('/api/unit', { method: 'PUT', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/unit?si=… → 204
|
||||
* 409 Unit in use
|
||||
*/
|
||||
// #region DELETE /api/unit?si=… — delete
|
||||
delete(si) {
|
||||
const params = new URLSearchParams({ si });
|
||||
return http(`/api/unit?${params}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
176
src/api/vfs.js
Normal file
176
src/api/vfs.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { http, clientIdHeaders } from './http.js';
|
||||
|
||||
export const vfs = {
|
||||
/**
|
||||
* GET /api/vfs
|
||||
* List files in the virtual filesystem for the selected company.
|
||||
* → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] }
|
||||
*/
|
||||
// #region GET /api/vfs — list
|
||||
list() {
|
||||
return http('/api/vfs');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/vfs/*path
|
||||
* Fetch raw content of a file (e.g. text for the editor).
|
||||
* @param {string} path
|
||||
*/
|
||||
// #region GET /api/vfs/*path — getText
|
||||
async getText(path) {
|
||||
const clean = String(path || '').replace(/^\/+/, '');
|
||||
const res = await fetch(`/api/vfs/${clean}?raw=1`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`);
|
||||
}
|
||||
return res.text();
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/vfs/*path
|
||||
* Create or update a file.
|
||||
* @param {string} path
|
||||
* @param {string} content
|
||||
* @param {string} [mime]
|
||||
*/
|
||||
// #region PUT /api/vfs/*path — save
|
||||
save(path, content, mime) {
|
||||
const clean = String(path || '').replace(/^\/+/, '');
|
||||
return http(`/api/vfs/${clean}`, {
|
||||
method: 'PUT',
|
||||
body: { content, mime },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* DELETE /api/vfs/*path
|
||||
* Delete a file.
|
||||
* @param {string} path
|
||||
*/
|
||||
// #region DELETE /api/vfs/*path — delete
|
||||
delete(path) {
|
||||
const clean = String(path || '').replace(/^\/+/, '');
|
||||
return http(`/api/vfs/${clean}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/upload
|
||||
* Upload multiple files or a single file to VFS.
|
||||
* @param {File[]|FileList|File} files
|
||||
* @param {string} [targetDir]
|
||||
*/
|
||||
// #region POST /api/vfs/upload — upload
|
||||
async upload(files, targetDir = '') {
|
||||
const formData = new FormData();
|
||||
if (targetDir) formData.append('targetDir', targetDir);
|
||||
const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files];
|
||||
for (const f of list) {
|
||||
formData.append('files', f);
|
||||
}
|
||||
const res = await fetch('/api/vfs/upload', {
|
||||
method: 'POST',
|
||||
headers: { ...clientIdHeaders() },
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/mkdir
|
||||
* Create a directory in VFS.
|
||||
* @param {string} path
|
||||
*/
|
||||
// #region POST /api/vfs/mkdir — mkdir
|
||||
mkdir(path) {
|
||||
const clean = String(path || '').replace(/^\/+/, '');
|
||||
return http('/api/vfs/mkdir', {
|
||||
method: 'POST',
|
||||
body: { path: clean },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/rename
|
||||
* Rename or move a file or directory in VFS.
|
||||
* @param {string} oldPath
|
||||
* @param {string} newPath
|
||||
*/
|
||||
// #region POST /api/vfs/rename — rename
|
||||
rename(oldPath, newPath) {
|
||||
const cleanOld = String(oldPath || '').replace(/^\/+/, '');
|
||||
const cleanNew = String(newPath || '').replace(/^\/+/, '');
|
||||
return http('/api/vfs/rename', {
|
||||
method: 'POST',
|
||||
body: { oldPath: cleanOld, newPath: cleanNew },
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/*path — run a `.applet.js` file from VFS.
|
||||
* Path goes in the URL, run params in the JSON body.
|
||||
* @param {string} path VFS-relative applet path (e.g. `demo.applet.js`)
|
||||
* @param {{ input?: object, timeout?: number, max_output?: number }} [options]
|
||||
* → 200 { ok, result, columns, rows, warnings?, stdout, consoleLines, durationMs, truncated? }
|
||||
* `result` is a typed CSV string (`;`-separated, header row first), `columns` are the
|
||||
* declared `{ name, type }` pairs, `rows` is the data-row count.
|
||||
*/
|
||||
// #region POST /api/vfs/*path — runApplet
|
||||
runApplet(path, options = {}) {
|
||||
const clean = String(path || '').replace(/^\/+/, '');
|
||||
const body = {};
|
||||
if (options.input !== undefined) body.input = options.input;
|
||||
if (options.timeout !== undefined) body.timeout = options.timeout;
|
||||
if (options.max_output !== undefined) body.max_output = options.max_output;
|
||||
return http(`/api/vfs/${clean}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* POST /api/vfs/skills/restore
|
||||
* Restores all system default .SKILL files into the company's VFS (overwrites existing files).
|
||||
* Requires no request body. Emits vfs SSE event.
|
||||
* → 200 { success: true, restored: string[], count: number }
|
||||
*/
|
||||
// #region POST /api/vfs/skills/restore — restoreSkills
|
||||
restoreSkills() {
|
||||
return http('/api/vfs/skills/restore', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/applets/restore
|
||||
* Restores all system default .applet.js example applets into the company's VFS (overwrites existing files).
|
||||
* Requires no request body. Emits vfs SSE event.
|
||||
* → 200 { success: true, restored: string[], count: number }
|
||||
*/
|
||||
// #region POST /api/vfs/applets/restore — restoreApplets
|
||||
restoreApplets() {
|
||||
return http('/api/vfs/applets/restore', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
54
src/api/warehouse.js
Normal file
54
src/api/warehouse.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company-scoped warehouses / Lager (selectedCompanyId from session).
|
||||
* Shape: Warehouse = { id, name }
|
||||
*/
|
||||
export const warehouse = {
|
||||
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
||||
// #region GET /api/warehouse — list
|
||||
list() {
|
||||
return http('/api/warehouse');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/warehouse
|
||||
* Body: { name, sort? } — empty name ⇒ 400 Name required.
|
||||
* → 201 { warehouse: Warehouse }
|
||||
*/
|
||||
// #region POST /api/warehouse — create
|
||||
create(body) {
|
||||
return http('/api/warehouse', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/warehouse/:id
|
||||
* Body: { name?, sort? }
|
||||
* → 200 { warehouse: Warehouse }
|
||||
*/
|
||||
// #region PATCH /api/warehouse/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/warehouse/order
|
||||
* Body: { warehouseIds: number[] }
|
||||
* → 200 { warehouseIds: number[] }
|
||||
*/
|
||||
// #region PUT /api/warehouse/order — order
|
||||
order(body) {
|
||||
return http('/api/warehouse/order', { method: 'PUT', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/warehouse/:id → 204 */
|
||||
// #region DELETE /api/warehouse/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
@@ -1,30 +1,62 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, createBrowserRouter } from 'react-router-dom';
|
||||
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import AppShell from './components/AppShell';
|
||||
import SectionLayout from './components/SectionLayout';
|
||||
|
||||
const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
|
||||
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
|
||||
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
|
||||
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
||||
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
||||
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
|
||||
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
|
||||
const LieferungLieferantenPage = lazy(() => import('./pages/lieferung/lieferanten'));
|
||||
const LieferungEditPage = lazy(() => import('./pages/lieferung/edit'));
|
||||
const AuftragAngebotPage = lazy(() => import('./pages/auftrag/index'));
|
||||
const AuftragAngebotNeuPage = lazy(() => import('./pages/auftrag/neu'));
|
||||
const AuftragAngebotEditPage = lazy(() => import('./pages/auftrag/edit'));
|
||||
const AuftragBestellungPage = lazy(() => import('./pages/auftrag/bestellung'));
|
||||
const AuftragBestellungNeuPage = lazy(() => import('./pages/auftrag/bestellungNeu'));
|
||||
const AuftragBestellungEditPage = lazy(() => import('./pages/auftrag/bestellungEdit'));
|
||||
const KundeListePage = lazy(() => import('./pages/kunde/index'));
|
||||
const KundeNeuPage = lazy(() => import('./pages/kunde/neu'));
|
||||
const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen'));
|
||||
const KundeEditPage = lazy(() => import('./pages/kunde/edit'));
|
||||
const InfosPage = lazy(() => import('./pages/infos/index'));
|
||||
const ArtikelUebersichtPage = lazy(() => import('./routes/artikel'));
|
||||
const ArtikelKategorienPage = lazy(() => import('./routes/artikel/kategorien'));
|
||||
const RedirectArtikelKategorien = lazy(() => import('./routes/artikel/RedirectArtikelKategorien'));
|
||||
const ArtikelLagerPage = lazy(() => import('./routes/artikel/lager'));
|
||||
const ArtikelMarkenPage = lazy(() => import('./routes/artikel/marken'));
|
||||
const ArtikelVorlagenPage = lazy(() => import('./routes/artikel/vorlagen'));
|
||||
const ArtikelMerkmalePage = lazy(() => import('./routes/artikel/merkmale'));
|
||||
const ArtikelSkuPage = lazy(() => import('./routes/artikel/_sku'));
|
||||
const LieferungListePage = lazy(() => import('./routes/lieferung'));
|
||||
const LieferungNeuPage = lazy(() => import('./routes/lieferung/neu'));
|
||||
const LieferungLieferantenPage = lazy(() => import('./routes/lieferung/lieferanten'));
|
||||
const LieferungEditPage = lazy(() => import('./routes/lieferung/_deliveryId'));
|
||||
const AuftragAngebotPage = lazy(() => import('./routes/auftrag'));
|
||||
const AuftragAngebotNeuPage = lazy(() => import('./routes/auftrag/neuesangebot'));
|
||||
const AuftragAngebotEditPage = lazy(() => import('./routes/auftrag/_quoteId'));
|
||||
const AuftragBestellungPage = lazy(() => import('./routes/auftrag/bestellung'));
|
||||
const AuftragBestellungNeuPage = lazy(() => import('./routes/auftrag/bestellung/neu'));
|
||||
const AuftragBestellungEditPage = lazy(() => import('./routes/auftrag/bestellung/_orderId'));
|
||||
const AuftragAusliefernPage = lazy(() => import('./routes/auftrag/ausliefern'));
|
||||
const AuftragRetourePage = lazy(() => import('./routes/auftrag/retoure'));
|
||||
const AuftragRetoureNeuPage = lazy(() => import('./routes/auftrag/retoure/neu'));
|
||||
const AuftragRetoureEditPage = lazy(() => import('./routes/auftrag/retoure/_retoureId'));
|
||||
const AuftragZahlungenPage = lazy(() => import('./routes/auftrag/zahlungen'));
|
||||
const AuftragVersandartenPage = lazy(() => import('./routes/auftrag/versandarten'));
|
||||
|
||||
const KundeListePage = lazy(() => import('./routes/kunde'));
|
||||
const KundeNeuPage = lazy(() => import('./routes/kunde/neu'));
|
||||
const KundeGruppenPage = lazy(() => import('./routes/kunde/gruppen'));
|
||||
const KundeEditPage = lazy(() => import('./routes/kunde/_entityId'));
|
||||
const StatisticsPage = lazy(() => import('./routes/infos/StatisticsPage'));
|
||||
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||
|
||||
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
|
||||
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
|
||||
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
|
||||
const CompanyAutomatDebugPage = import.meta.env.DEV
|
||||
? lazy(() => import('./routes/debug/CompanyAutomatPage'))
|
||||
: null;
|
||||
|
||||
function LoginRedirect() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const returnTo = params.get('returnTo');
|
||||
return <Navigate to={returnTo || '/artikel'} replace />;
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/oauth/authorize',
|
||||
element: <OAuthAuthorizePage />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginRedirect />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
@@ -40,7 +72,17 @@ const routes = [
|
||||
{ path: ':deliveryId', element: <LieferungEditPage /> },
|
||||
],
|
||||
},
|
||||
{ path: 'infos', element: <InfosPage /> },
|
||||
{
|
||||
path: 'infos',
|
||||
element: <SectionLayout sectionPath="/infos" />,
|
||||
children: [
|
||||
{ index: true, element: <ArticleHistoryPage /> },
|
||||
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
|
||||
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
|
||||
{ path: 'ledgerhistory', element: <LedgerHistoryPage /> },
|
||||
{ path: 'statistics', element: <StatisticsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'kategorien/*',
|
||||
element: <SectionLayout sectionPath="/artikel" />,
|
||||
@@ -53,6 +95,9 @@ const routes = [
|
||||
{ index: true, element: <ArtikelUebersichtPage /> },
|
||||
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
|
||||
{ path: 'lager', element: <ArtikelLagerPage /> },
|
||||
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
||||
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
|
||||
{ path: 'merkmale', element: <ArtikelMerkmalePage /> },
|
||||
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
||||
],
|
||||
},
|
||||
@@ -60,11 +105,22 @@ const routes = [
|
||||
path: 'auftrag',
|
||||
element: <SectionLayout sectionPath="/auftrag" />,
|
||||
children: [
|
||||
{ index: true, element: <AuftragAngebotPage /> },
|
||||
{ path: 'neu', element: <AuftragAngebotNeuPage /> },
|
||||
{ path: 'bestellung', element: <AuftragBestellungPage /> },
|
||||
{ index: true, element: <AuftragBestellungPage /> },
|
||||
{ path: 'angebot', element: <AuftragAngebotPage /> },
|
||||
{ path: 'angebot/:quoteId', element: <AuftragAngebotEditPage /> },
|
||||
{ path: 'neuesangebot', element: <AuftragAngebotNeuPage /> },
|
||||
{ path: 'angebot/neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
|
||||
{ path: 'neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
|
||||
{ path: 'bestellung', element: <Navigate to="/auftrag" replace /> },
|
||||
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
||||
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
||||
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
||||
{ path: 'retoure', element: <AuftragRetourePage /> },
|
||||
{ path: 'retoure/neu', element: <AuftragRetoureNeuPage /> },
|
||||
{ path: 'retoure/:retoureId', element: <AuftragRetoureEditPage /> },
|
||||
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
||||
{ path: 'versandarten', element: <AuftragVersandartenPage /> },
|
||||
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
|
||||
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
||||
],
|
||||
},
|
||||
@@ -78,6 +134,19 @@ const routes = [
|
||||
{ path: ':entityId', element: <KundeEditPage /> },
|
||||
],
|
||||
},
|
||||
...(import.meta.env.DEV
|
||||
? [
|
||||
{
|
||||
path: 'debug',
|
||||
element: <SectionLayout sectionPath="/debug" />,
|
||||
children: [
|
||||
{ index: true, element: <CompanyAutomatDebugPage /> },
|
||||
{ path: 'company', element: <CompanyAutomatDebugPage /> },
|
||||
{ path: 'companyautomat', element: <CompanyAutomatDebugPage /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ path: '*', element: <Navigate to="/artikel" replace /> },
|
||||
],
|
||||
},
|
||||
@@ -90,3 +159,7 @@ export const router =
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.router = router;
|
||||
}
|
||||
|
||||
export default function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
15
src/assets/printers/index.js
Normal file
15
src/assets/printers/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import zd41x from './zd41x.png';
|
||||
|
||||
// Library of printer images. Entries are matched in order against the
|
||||
// printer model (as reported by the agent), case-insensitively.
|
||||
const PRINTER_IMAGES = [
|
||||
{ pattern: /^ZD41/i, image: zd41x }, // ZD410, ZD411, ZD410-200dpi, ...
|
||||
];
|
||||
|
||||
export function getPrinterImage(model) {
|
||||
if (!model) return null;
|
||||
const entry = PRINTER_IMAGES.find(({ pattern }) => pattern.test(String(model).trim()));
|
||||
return entry?.image ?? null;
|
||||
}
|
||||
|
||||
export default PRINTER_IMAGES;
|
||||
BIN
src/assets/printers/zd41x.png
Normal file
BIN
src/assets/printers/zd41x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
src/assets/quixpos-logo.png
Normal file
BIN
src/assets/quixpos-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
126
src/automats/articleDetailAutomat.js
Normal file
126
src/automats/articleDetailAutomat.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @file articleDetailAutomat.js
|
||||
* @description SKU-keyed automats for article detail tabs: article history
|
||||
* (`articleHistoryAutomat`) and assigned suppliers (`articleSupplierAutomat`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `sseAutomat`: Calls `articleHistoryAutomat.reload(sku)` and
|
||||
* `articleSupplierAutomat.reload(sku)` via `invalidateArticle(sku)` when an
|
||||
* `article-<sku>` SSE event is received.
|
||||
* - UI / Router: Triggers keyed `.load(sku)` or `.key(sku)` when viewing
|
||||
* history or supplier details of a specific article.
|
||||
*/
|
||||
|
||||
import { Automat, KeyedAutomat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleHistoryEntry
|
||||
* @property {string|number} id History entry id.
|
||||
* @property {string} date Timestamp (ISO).
|
||||
* @property {string} text Human-readable change description.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSupplierRow
|
||||
* @property {string|number} id Supplier link id.
|
||||
* @property {string|number} entityId Supplier id.
|
||||
* @property {string|null} sku Supplier SKU.
|
||||
* @property {string|null} name Supplier name.
|
||||
* @property {number|null} pu Pack unit.
|
||||
* @property {number|null} stock Supplier stock.
|
||||
* @property {number|null} netPrice Net purchase price.
|
||||
* @property {number|null} taxRate Tax rate in percent.
|
||||
*/
|
||||
|
||||
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* Registry of SKU-specific chunk-list automats for article history.
|
||||
* @type {Map<string, import('react-automat').Automat>}
|
||||
*/
|
||||
const skuHistoryAutomats = new Map();
|
||||
|
||||
/**
|
||||
* Gets or creates the chunk-list automat for a specific article's history.
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
|
||||
*/
|
||||
export function getArticleHistoryAutomat(sku) {
|
||||
const rawKey = String(sku);
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
let inst = skuHistoryAutomats.get(key);
|
||||
if (!inst) {
|
||||
inst = createChunkListAutomat({
|
||||
name: `articleHistory:${rawKey}`,
|
||||
chunkSize: 50,
|
||||
maxLruChunks: 50,
|
||||
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
|
||||
});
|
||||
skuHistoryAutomats.set(key, inst);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
|
||||
*
|
||||
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
|
||||
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
|
||||
*/
|
||||
export const articleHistoryAutomat = {
|
||||
get: getArticleHistoryAutomat,
|
||||
key: getArticleHistoryAutomat,
|
||||
has(sku) {
|
||||
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
|
||||
},
|
||||
async load(sku) {
|
||||
const inst = getArticleHistoryAutomat(sku);
|
||||
return inst.getItems();
|
||||
},
|
||||
async reload(sku) {
|
||||
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
|
||||
if (inst) {
|
||||
await inst.actions.reload();
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
for (const inst of skuHistoryAutomats.values()) {
|
||||
inst.actions.reset();
|
||||
}
|
||||
skuHistoryAutomats.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// Reset on logout or company change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
articleHistoryAutomat.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||
*
|
||||
* @type {KeyedAutomat<Array<ArticleSupplierRow>>}
|
||||
*/
|
||||
export const articleSupplierAutomat = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'articleSuppliers',
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getSuppliers(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.syncArticleSuppliers) {
|
||||
comp.actions.syncArticleSuppliers(sku, list);
|
||||
}
|
||||
return list;
|
||||
},
|
||||
},
|
||||
);
|
||||
26
src/automats/articleHistoryAutomat.js
Normal file
26
src/automats/articleHistoryAutomat.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file articleHistoryAutomat.js
|
||||
* @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
|
||||
* article audit logs and history entries with multi-filter caching and LRU eviction.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||
* `selectedCompanyId` changes.
|
||||
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||
*/
|
||||
|
||||
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||
import { article } from '../api';
|
||||
|
||||
export const articleHistoryAutomat = createChunkListAutomat({
|
||||
name: 'articleHistory',
|
||||
chunkSize: 50,
|
||||
maxLruChunks: 50,
|
||||
fetchPage: (params) => article.getHistory(params),
|
||||
});
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export const articleHistoryGridAutomat = articleHistoryAutomat;
|
||||
|
||||
export default articleHistoryAutomat;
|
||||
499
src/automats/articleIndexAutomat.js
Normal file
499
src/automats/articleIndexAutomat.js
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* @file articleIndexAutomat.js
|
||||
* @description Company article search index used by the `/artikel` overview,
|
||||
* the manifest picker and the article-list pickers.
|
||||
*
|
||||
* The index is **derived**, not fetched: it is the de-duplicated union of the
|
||||
* articles of every loaded category view. `categoryAutomat.loadTree()` warms
|
||||
* those views right after `GET /api/category/tree` resolves, so the index is
|
||||
* ready without any additional request.
|
||||
*
|
||||
* `GET /api/articleIndex` (whole-company window) and the `/article/:sku/articlelist`
|
||||
* calls are no longer used by this store.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `categoryViewAutomat`: Subscribes to the keyed view store, so a view load,
|
||||
* reload, article creation or article removal re-derives the index.
|
||||
* - `categoryAutomat`: Re-derives once `treeStatus === 'succeeded'` and the
|
||||
* post-tree view preload (`viewsLoading`) settled; `reset()` on company switch.
|
||||
* - `authAutomat`: Resets to idle on `'anon'`.
|
||||
* - `sseAutomat`: `actions.reload()` on an `'articles'` event — now only
|
||||
* re-derives the index from the invalidated category views.
|
||||
* - `artikelAutomat`: `setArticleIndexName(sku, name)` on optimistic renames and
|
||||
* `actions.reset()` on mutation failure rollback.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`.
|
||||
* - `ArticleSearchDialog`, `ArticleViewLists`: `actions.ensureLoaded()` for a
|
||||
* flat article list.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
import {
|
||||
categoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
loadAllCategoryViews,
|
||||
invalidateAllCategoryViews,
|
||||
updateArticleInCategoryViews,
|
||||
removeArticleBySku,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleIndexRecord
|
||||
* @property {string|number} id Article id.
|
||||
* @property {string} sku Article SKU.
|
||||
* @property {string} name Article name.
|
||||
* @property {Array<string>} eans Article EANs (not yet served per category — see below).
|
||||
* @property {string|null} categories Comma-joined category ids.
|
||||
* @property {boolean} isParent Whether the article has child variants.
|
||||
*
|
||||
* @typedef {Object} ArticleIndexState
|
||||
* @property {Array<ArticleIndexRecord>} items Derived index records.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Lifecycle state.
|
||||
* @property {Error|string|null} error Last error.
|
||||
*/
|
||||
|
||||
/** @type {ArticleIndexState} */
|
||||
const initialState = {
|
||||
items: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let articleIndexAutomat;
|
||||
|
||||
/** In-flight `ensureLoaded()` promise so concurrent callers share one rebuild. */
|
||||
let inflightEnsure = null;
|
||||
|
||||
/** Pending coalesced rebuild, scheduled on the microtask queue. */
|
||||
let rebuildScheduled = false;
|
||||
|
||||
/**
|
||||
* Schedules a rebuild on the next microtask.
|
||||
*
|
||||
* Warming every category view fires one notification per view; coalescing keeps
|
||||
* the derivation at roughly O(articles) instead of O(categories × articles).
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function scheduleRebuild() {
|
||||
if (rebuildScheduled) return;
|
||||
rebuildScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
rebuildScheduled = false;
|
||||
actions.rebuild();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a category-view article row into an index record.
|
||||
*
|
||||
* @param {object} article Raw category-view article row.
|
||||
* @param {number|string|null} categoryId Category the article was loaded from.
|
||||
* @returns {ArticleIndexRecord} Index record.
|
||||
*/
|
||||
function toIndexRecord(article, categoryId) {
|
||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||
const childCount = Number(article.childCount) || 0;
|
||||
const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null);
|
||||
const warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
|
||||
const trackStock = article.track_stock ?? article.trackStock;
|
||||
const stock = trackStock === false
|
||||
? null
|
||||
: (article.stock != null && !Number.isNaN(Number(article.stock))
|
||||
? Number(article.stock)
|
||||
: (trackStock || warehouses.length > 0
|
||||
? warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
|
||||
: null));
|
||||
const available = article.available != null && !Number.isNaN(Number(article.available))
|
||||
? Number(article.available)
|
||||
: stock;
|
||||
const categories = categoryId != null
|
||||
? String(categoryId)
|
||||
: (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null));
|
||||
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? null;
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? null;
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
|
||||
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
|
||||
|
||||
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||
|
||||
return {
|
||||
...article,
|
||||
id: article.id,
|
||||
sku: article.sku,
|
||||
name: article.name,
|
||||
descLength,
|
||||
seo: article.seo ?? null,
|
||||
price,
|
||||
net: article.net ?? price,
|
||||
stock,
|
||||
available,
|
||||
warehouses,
|
||||
articlewarehousesummary: Array.isArray(article.articlewarehousesummary)
|
||||
? article.articlewarehousesummary
|
||||
: [],
|
||||
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
||||
categories,
|
||||
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
||||
childCount,
|
||||
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
|
||||
parentSku: article.parentSku ?? article.parent_sku ?? null,
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the articles of all loaded category views into unique index records.
|
||||
*
|
||||
* An article assigned to several categories is emitted once, with every category
|
||||
* it was seen in accumulated in `categories`.
|
||||
*
|
||||
* @param {Array<{categoryId: number|string, articles: Array<object>}>} views Loaded view data.
|
||||
* @returns {Array<ArticleIndexRecord>} Deduplicated index records.
|
||||
*/
|
||||
export function buildArticleIndex(views) {
|
||||
const bySku = new Map();
|
||||
|
||||
for (const view of views) {
|
||||
const categoryId = view?.categoryId;
|
||||
for (const article of view?.articles ?? []) {
|
||||
if (article?.sku == null) continue;
|
||||
const key = String(article.sku);
|
||||
const existing = bySku.get(key);
|
||||
if (!existing) {
|
||||
bySku.set(key, toIndexRecord(article, categoryId));
|
||||
continue;
|
||||
}
|
||||
if (categoryId != null) {
|
||||
const ids = new Set(String(existing.categories ?? '').split(',').filter(Boolean));
|
||||
ids.add(String(categoryId));
|
||||
existing.categories = Array.from(ids).join(',');
|
||||
}
|
||||
const childCount = Number(article.childCount) || 0;
|
||||
if (childCount > (Number(existing.childCount) || 0)) {
|
||||
existing.childCount = childCount;
|
||||
}
|
||||
existing.isParent = Boolean(
|
||||
existing.isParent ||
|
||||
(article.isParent ?? article.is_parent) ||
|
||||
(childCount > 0)
|
||||
);
|
||||
if (existing.price == null && (article.price != null || article.net != null)) {
|
||||
const p = article.price ?? article.net ?? article.vknetto;
|
||||
existing.price = p;
|
||||
existing.net = article.net ?? p;
|
||||
}
|
||||
if (existing.stock == null && (article.warehouses?.length > 0 || article.stock != null)) {
|
||||
const rec = toIndexRecord(article, categoryId);
|
||||
if (rec.stock != null) {
|
||||
existing.stock = rec.stock;
|
||||
existing.available = rec.available;
|
||||
existing.warehouses = rec.warehouses;
|
||||
existing.articlewarehousesummary = rec.articlewarehousesummary;
|
||||
}
|
||||
}
|
||||
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
|
||||
existing.descLength = incomingDescLen;
|
||||
}
|
||||
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
|
||||
existing.seo = article.seo;
|
||||
}
|
||||
if (Array.isArray(article.eans) && article.eans.length > 0) {
|
||||
const mergedEans = new Set(existing.eans ?? []);
|
||||
for (const e of article.eans) if (e) mergedEans.add(e);
|
||||
existing.eans = Array.from(mergedEans);
|
||||
}
|
||||
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
|
||||
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
|
||||
}
|
||||
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
|
||||
existing.parentSku = article.parentSku ?? article.parent_sku;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySku.values());
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Rebuilds the index from the currently loaded category views.
|
||||
*
|
||||
* Does not hit the network: the data comes from the view cache that
|
||||
* `categoryAutomat.loadTree()` populated.
|
||||
*
|
||||
* @returns {Array<ArticleIndexRecord>} Derived records.
|
||||
*/
|
||||
rebuild() {
|
||||
const items = buildArticleIndex(collectLoadedCategoryViewData());
|
||||
articleIndexAutomat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves once the index holds data, rebuilding it when necessary.
|
||||
*
|
||||
* Waits for the category tree and for the post-tree view preload, so the
|
||||
* returned list is complete rather than a partial early snapshot.
|
||||
*
|
||||
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
|
||||
*/
|
||||
async ensureLoaded() {
|
||||
if (inflightEnsure) return inflightEnsure;
|
||||
if (articleIndexAutomat.state.status === 'ready' && articleIndexAutomat.state.items.length > 0) {
|
||||
return articleIndexAutomat.state.items;
|
||||
}
|
||||
|
||||
articleIndexAutomat.setState({ status: 'loading', error: null });
|
||||
inflightEnsure = (async () => {
|
||||
await whenCategoryTreeReady();
|
||||
// loadTree() already warms every view; this is the idempotent safety net
|
||||
// for a tree that finished loading before this automat subscribed.
|
||||
if (categoryAutomat.state.viewsLoading) {
|
||||
await loadAllCategoryViews();
|
||||
}
|
||||
return actions.rebuild();
|
||||
})()
|
||||
.catch((error) => {
|
||||
articleIndexAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
inflightEnsure = null;
|
||||
});
|
||||
|
||||
return inflightEnsure;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-derives the index after the category views were invalidated by SSE.
|
||||
*
|
||||
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
|
||||
*/
|
||||
async reload() {
|
||||
return actions.ensureLoaded();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without re-deriving it.
|
||||
*
|
||||
* @param {Array<ArticleIndexRecord>} items Records to cache.
|
||||
* @returns {Array<ArticleIndexRecord>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
articleIndexAutomat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refetches and updates a single article in the index and category views by SKU.
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @returns {Promise<object|null>} Fresh article or null.
|
||||
*/
|
||||
async updateArticleBySku(sku) {
|
||||
const key = String(sku);
|
||||
try {
|
||||
const res = await articleApi.getBySku(key);
|
||||
const article = res?.article;
|
||||
if (article) {
|
||||
updateArticleIndexRecord(article);
|
||||
return article;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err?.status === 404 || err?.statusCode === 404) {
|
||||
removeArticleBySku(key);
|
||||
const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key);
|
||||
articleIndexAutomat.setState({ items: nextItems });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset to idle state (logout / company switch). */
|
||||
reset() {
|
||||
inflightEnsure = null;
|
||||
rebuildScheduled = false;
|
||||
articleIndexAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Derived company article search index.
|
||||
*
|
||||
* @type {Automat<ArticleIndexState>}
|
||||
*/
|
||||
articleIndexAutomat = new Automat(initialState, actions, { name: 'articleIndex' });
|
||||
|
||||
export { articleIndexAutomat };
|
||||
|
||||
// Re-derive whenever a category view loads, reloads or is patched.
|
||||
categoryViews.subscribe(() => {
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
// Re-derive once the tree finished loading and its view preload settled.
|
||||
categoryAutomat.subscribe((state) => {
|
||||
if (state.treeStatus === 'succeeded' && state.viewsLoading === false) {
|
||||
scheduleRebuild();
|
||||
}
|
||||
});
|
||||
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
|
||||
scheduleRebuild();
|
||||
}
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') actions.reset();
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.onArticle) {
|
||||
sseAutomat.actions.onArticle(async (sku) => {
|
||||
if (articleIndexAutomat.state.status === 'ready') {
|
||||
await actions.updateArticleBySku(sku);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void actions.reload().catch(() => {});
|
||||
});
|
||||
|
||||
sseAutomat.actions.on('warehouse', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void actions.reload().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single article record in the derived index and loaded category views.
|
||||
*
|
||||
* @param {object} article Article detail or row.
|
||||
* @returns {Array<ArticleIndexRecord>|undefined} Updated items array.
|
||||
*/
|
||||
export function updateArticleIndexRecord(article) {
|
||||
if (!article || article.sku == null) return;
|
||||
const key = String(article.sku);
|
||||
updateArticleInCategoryViews(article);
|
||||
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items || !items.length) return;
|
||||
|
||||
const existingIndex = items.findIndex((r) => String(r.sku) === key);
|
||||
const categoryIds = Array.isArray(article.categoryIds)
|
||||
? article.categoryIds.map(String)
|
||||
: [];
|
||||
const primaryCatId = categoryIds[0] ?? null;
|
||||
|
||||
const record = toIndexRecord(article, primaryCatId);
|
||||
if (categoryIds.length > 0) {
|
||||
record.categories = categoryIds.join(',');
|
||||
}
|
||||
|
||||
let nextItems;
|
||||
if (existingIndex >= 0) {
|
||||
const existing = items[existingIndex];
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const hasPhotosProp = 'photos' in article;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||
|
||||
nextItems = [...items];
|
||||
nextItems[existingIndex] = {
|
||||
...existing,
|
||||
...record,
|
||||
categories: record.categories ?? existing.categories,
|
||||
brand: article.brand ?? existing.brand,
|
||||
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
} else {
|
||||
nextItems = [...items, record];
|
||||
}
|
||||
|
||||
articleIndexAutomat.setState({ items: nextItems });
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches a single record's name in the derived index (optimistic update).
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @param {string} name New article name.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setArticleIndexName(sku, name) {
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items.length) return;
|
||||
articleIndexAutomat.actions.replace(
|
||||
items.map((row) =>
|
||||
String(row.sku) === String(sku) ? { ...row, name } : row,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches a single record's stock in the derived index (e.g. after detail load or stock adjustment).
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @param {object} stockData Stock updates.
|
||||
* @param {number|null} [stockData.stock] Total stock.
|
||||
* @param {number|null} [stockData.available] Available stock.
|
||||
* @param {Array<object>} [stockData.warehouses] Warehouse array.
|
||||
* @param {Array<object>} [stockData.articlewarehousesummary] Summary array.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setArticleIndexStock(sku, { stock, available, warehouses, articlewarehousesummary } = {}) {
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items.length) return;
|
||||
articleIndexAutomat.actions.replace(
|
||||
items.map((row) => {
|
||||
if (String(row.sku) !== String(sku)) return row;
|
||||
const whs = warehouses ?? row.warehouses ?? [];
|
||||
const computedStock = stock !== undefined
|
||||
? stock
|
||||
: (whs.length > 0 ? whs.reduce((sum, w) => sum + (Number(w.stock) || 0), 0) : row.stock);
|
||||
const computedAvailable = available !== undefined
|
||||
? available
|
||||
: (computedStock !== undefined ? computedStock : row.available);
|
||||
return {
|
||||
...row,
|
||||
stock: computedStock,
|
||||
available: computedAvailable,
|
||||
...(warehouses ? { warehouses } : {}),
|
||||
...(articlewarehousesummary ? { articlewarehousesummary } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the derived index records.
|
||||
*
|
||||
* @param {ArticleIndexState} state Automat state.
|
||||
* @returns {Array<ArticleIndexRecord>} Current records.
|
||||
*/
|
||||
export function selectArticleIndexItems(state) {
|
||||
return state.items;
|
||||
}
|
||||
|
||||
export default articleIndexAutomat;
|
||||
1052
src/automats/articleSearchAutomat.js
Normal file
1052
src/automats/articleSearchAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
338
src/automats/artikelAutomat.js
Normal file
338
src/automats/artikelAutomat.js
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @file artikelAutomat.js
|
||||
* @description In-memory reactive cache and mutator for full single-article
|
||||
* detail payloads (`GET /api/article/:sku`), with inflight request deduplication.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `sseAutomat`: Calls `reloadArticle(sku)` via `invalidateArticle(sku)` when an
|
||||
* `article-<sku>` SSE mutation event arrives.
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.clear()` when
|
||||
* status transitions to `'anon'` (logout).
|
||||
* - UI / Routes: Calls `loadArticle(sku)` or mutations (`setName`, `setEans`,
|
||||
* `setBrand`, `setCategories`, `setPrices`, `setWeight`, etc.).
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - `articleIndexAutomat`: Calls `setArticleIndexName(sku, name)` on optimistic
|
||||
* article renames, and `articleIndexAutomat.actions.reset()` on mutation failure rollback.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
import articleIndexAutomat, {
|
||||
setArticleIndexName,
|
||||
setArticleIndexStock,
|
||||
updateArticleIndexRecord,
|
||||
} from './articleIndexAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArtikelState
|
||||
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
|
||||
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
|
||||
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
|
||||
*/
|
||||
|
||||
/** @type {ArtikelState} */
|
||||
const initialState = {
|
||||
bySku: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
|
||||
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
|
||||
*
|
||||
* @type {Map<string, Promise<object>>}
|
||||
*/
|
||||
const inflight = new Map();
|
||||
|
||||
/** @type {Automat<ArtikelState>} */
|
||||
let artikelAutomat;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads an article into the shared cache, deduplicating concurrent callers.
|
||||
* Resolves with the cached article when it is already present.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {Promise<object>} Loaded article detail.
|
||||
*/
|
||||
loadArticle(sku) {
|
||||
const key = String(sku ?? '').trim();
|
||||
if (!key) {
|
||||
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
|
||||
}
|
||||
|
||||
const cached = artikelAutomat.state.bySku[key];
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = articleApi
|
||||
.getBySku(key)
|
||||
.then((res) => {
|
||||
requireObject(res, 'GET /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
requireDefined(res.article.sku, 'article.sku');
|
||||
return artikelAutomat.actions.setArticle(res.article);
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches an article, keeping the cached copy until the new one
|
||||
* arrives. Use for SSE invalidation and pull-to-refresh.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {Promise<object>} Fresh article detail.
|
||||
*/
|
||||
async reloadArticle(sku) {
|
||||
const key = String(sku ?? '').trim();
|
||||
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
|
||||
|
||||
const res = await articleApi.getBySku(key);
|
||||
requireObject(res, 'GET /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
requireDefined(res.article.sku, 'article.sku');
|
||||
return artikelAutomat.actions.setArticle(res.article);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
|
||||
* response so every subscriber sees the same object.
|
||||
*
|
||||
* @param {object} article Article detail with at least a `sku`.
|
||||
* @returns {object} The stored article.
|
||||
*/
|
||||
setArticle(article) {
|
||||
requireObject(article, 'article');
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
artikelAutomat.setState({
|
||||
bySku: { ...artikelAutomat.state.bySku, [key]: article },
|
||||
});
|
||||
updateArticleIndexRecord(article);
|
||||
return article;
|
||||
},
|
||||
|
||||
/**
|
||||
* Patches the cached article name without a round-trip (optimistic update).
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {string} name New name.
|
||||
* @returns {object|null} Updated article, or `null` when not cached.
|
||||
*/
|
||||
setArticleName(sku, name) {
|
||||
const key = String(sku);
|
||||
const current = artikelAutomat.state.bySku[key];
|
||||
if (!current) return null;
|
||||
return artikelAutomat.actions.setArticle({ ...current, name });
|
||||
},
|
||||
|
||||
/**
|
||||
* Drops an article from the cache (e.g. after deletion), forcing the next
|
||||
* `loadArticle` to hit the backend.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
clearArticle(sku) {
|
||||
const key = String(sku);
|
||||
if (!(key in artikelAutomat.state.bySku)) return;
|
||||
const bySku = { ...artikelAutomat.state.bySku };
|
||||
delete bySku[key];
|
||||
artikelAutomat.setState({ bySku });
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku with optimistic name handling.
|
||||
*
|
||||
* A `name` in the body is applied to both caches immediately so the tile
|
||||
* does not flicker, then rolled back from the server on failure.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock?, desc?, seo? }`).
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async update(sku, body) {
|
||||
const key = String(sku);
|
||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||
if (nameChange) {
|
||||
setArticleIndexName(key, body.name);
|
||||
actions.setArticleName(key, body.name);
|
||||
}
|
||||
try {
|
||||
await articleApi.update(key, body);
|
||||
return { article: await actions.reloadArticle(key) };
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||
articleIndexAutomat.actions.reset();
|
||||
if (hasArticle(key)) {
|
||||
void actions.loadArticle(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/eans → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<string>} eans Replacement EAN list.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setEans(sku, eans) {
|
||||
await articleApi.setEansBySku(String(sku), eans);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {{ brandId: number|null }} body Brand payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setBrand(sku, body) {
|
||||
await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/categories → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<number>} categoryIds Replacement category ids.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setCategories(sku, categoryIds) {
|
||||
await articleApi.setCategories(String(sku), categoryIds);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/prices → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<object>} prices Net price rows.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setPrices(sku, prices) {
|
||||
await articleApi.setPrices(String(sku), prices);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Weight payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setWeight(sku, body) {
|
||||
await articleApi.setWeight(String(sku), body);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/track_stock → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {boolean|object} body Flag or full body.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setTrackStock(sku, body) {
|
||||
await articleApi.setTrackStock(String(sku), body);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets every cached article.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
artikelAutomat.setState({ bySku: {} });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared cache for article detail payloads. State lives in module scope so
|
||||
* navigating between articles (and remounting the view) reuses the cache
|
||||
* instead of re-fetching.
|
||||
*
|
||||
* @type {Automat<ArtikelState>}
|
||||
*/
|
||||
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
|
||||
|
||||
/**
|
||||
* Reads a cached article synchronously.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {object|null} Cached article detail, or `null`.
|
||||
*/
|
||||
export function getArticle(sku) {
|
||||
if (sku == null) return null;
|
||||
return artikelAutomat.state.bySku[String(sku)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an article detail payload is currently cached.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {boolean} `true` when cached.
|
||||
*/
|
||||
export function hasArticle(sku) {
|
||||
return getArticle(sku) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a selector for one SKU, so a component can subscribe to just that
|
||||
* article instead of every cache change.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {(state: ArtikelState) => object|null} Selector function.
|
||||
*/
|
||||
export function createArticleSelector(sku) {
|
||||
const key = sku == null ? null : String(sku);
|
||||
return (state) => (key == null ? null : state.bySku[key] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
|
||||
*/
|
||||
export function selectArticle(sku) {
|
||||
return artikelAutomat.select(createArticleSelector(sku));
|
||||
}
|
||||
|
||||
// Drop cached articles on logout. A company switch keeps them; the SSE hub
|
||||
// reconnects and revalidates whatever is on screen.
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
actions.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.onArticle) {
|
||||
sseAutomat.actions.onArticle((sku) => {
|
||||
const key = String(sku);
|
||||
if (key in artikelAutomat.state.bySku) {
|
||||
void actions.reloadArticle(key).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default artikelAutomat;
|
||||
385
src/automats/authAutomat.js
Normal file
385
src/automats/authAutomat.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @file authAutomat.js
|
||||
* @description Central authentication and multi-tenant session manager.
|
||||
* Tracks current user, Google OAuth client ID, active company selection, and
|
||||
* the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
|
||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`.
|
||||
* - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
|
||||
* `createCompany(payload)`, or `logout()`.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`.
|
||||
* - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet).
|
||||
* - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat`
|
||||
* are mounted and company APIs are triggered.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { auth, company, number as numberApi } 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 {number|null} schemaVersion Database schema version returned by the backend.
|
||||
* @property {boolean} vhost Whether vhost mode is enabled.
|
||||
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
|
||||
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
||||
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
||||
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
||||
* @property {string|null} retryNotification Message indicating a pending retry (e.g. 502 Bad Gateway).
|
||||
*/
|
||||
|
||||
/** 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,
|
||||
schemaVersion: null,
|
||||
vhost: false,
|
||||
vhostSettings: null,
|
||||
status: 'loading',
|
||||
hasAuth: false,
|
||||
hasCompany: false,
|
||||
retryNotification: null,
|
||||
};
|
||||
|
||||
let emit = () => { };
|
||||
|
||||
// #region helpers
|
||||
/**
|
||||
* Detects whether an error represents an HTTP 502 Bad Gateway response.
|
||||
*
|
||||
* @param {any} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function is502BadGateway(err) {
|
||||
if (!err) return false;
|
||||
if (Number(err.status) === 502) return true;
|
||||
const msg = String(err.message || '');
|
||||
return /502|bad\s*gateway/i.test(msg);
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function normalizePicture(picture) {
|
||||
if (!picture || typeof picture !== 'string') return null;
|
||||
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
|
||||
return picture;
|
||||
}
|
||||
const mime = picture.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
|
||||
return `data:${mime};base64,${picture}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const merged = { ...current, ...incoming, selectedCompanyId };
|
||||
if (merged.picture) {
|
||||
merged.picture = normalizePicture(merged.picture);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`).
|
||||
*
|
||||
* @param {AuthUser|null} user
|
||||
* @param {'loading'|'anon'|'authed'} status
|
||||
* @param {object} [extra]
|
||||
* @returns {Partial<AuthState>}
|
||||
*/
|
||||
function buildAuthState(user, status, extra = {}) {
|
||||
const hasAuth = status === 'authed' && Boolean(user);
|
||||
const hasCompany = hasAuth && user?.selectedCompanyId != null;
|
||||
return {
|
||||
user: hasAuth ? user : null,
|
||||
status,
|
||||
hasAuth,
|
||||
hasCompany,
|
||||
vhost: authAutomat?.state?.vhost ?? false,
|
||||
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
|
||||
retryNotification: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function syncSse(state) {
|
||||
const sseAutomat = Automat.get('sse');
|
||||
if (sseAutomat?.actions?.syncWithAuth) {
|
||||
sseAutomat.actions.syncWithAuth(state);
|
||||
}
|
||||
}
|
||||
|
||||
function applySession(nextSession) {
|
||||
const googleClientId =
|
||||
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||
const schemaVersion =
|
||||
nextSession.schemaVersion !== undefined
|
||||
? nextSession.schemaVersion
|
||||
: (authAutomat.state.schemaVersion ?? null);
|
||||
const vhost =
|
||||
nextSession.vhost !== undefined
|
||||
? Boolean(nextSession.vhost)
|
||||
: (authAutomat.state.vhost ?? false);
|
||||
const vhostSettings =
|
||||
nextSession.vhostSettings !== undefined
|
||||
? nextSession.vhostSettings
|
||||
: (authAutomat.state.vhostSettings ?? null);
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (authAutomat.state.status !== 'authed') {
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
} else {
|
||||
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
|
||||
}
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return nextSession;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
let authAutomat;
|
||||
let inFlightBootstrap = null;
|
||||
|
||||
const actions = {
|
||||
// #region bootstrapSession
|
||||
// MARK: calls auth.me from ../api
|
||||
/**
|
||||
* Restores an existing session or requests it from the authentication API.
|
||||
* Retries automatically every 5 seconds if a 502 Bad Gateway is received.
|
||||
*
|
||||
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
|
||||
* Optional preloaded session.
|
||||
* @returns {Promise<Object>} Resolved session response.
|
||||
*/
|
||||
async bootstrapSession(session) {
|
||||
if (session) {
|
||||
return applySession(session);
|
||||
}
|
||||
|
||||
if (inFlightBootstrap) {
|
||||
return inFlightBootstrap;
|
||||
}
|
||||
|
||||
inFlightBootstrap = (async () => {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const nextSession = await auth.me();
|
||||
return applySession(nextSession);
|
||||
} catch (error) {
|
||||
if (is502BadGateway(error)) {
|
||||
attempt++;
|
||||
authAutomat.setState({
|
||||
retryNotification: `Server nicht erreichbar (502 Bad Gateway) – erneuter Versuch in 5s…${attempt > 1 ? ` (Versuch ${attempt})` : ''}`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (authAutomat.state.status === 'loading') {
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(null, 'anon', { retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
inFlightBootstrap = null;
|
||||
});
|
||||
|
||||
return inFlightBootstrap;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region loginWithGoogle
|
||||
// MARK: calls auth.loginWithGoogle from ../api
|
||||
/**
|
||||
* 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)');
|
||||
}
|
||||
const user = mergeUser(authAutomat.state.user, session.user);
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', {
|
||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
|
||||
})
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return session;
|
||||
} catch (error) {
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region selectCompany
|
||||
// MARK: calls company.select from ../api
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region createCompany
|
||||
// MARK: calls company.create and company.select from ../api
|
||||
/**
|
||||
* 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 { numbers, ...companyPayload } = payload || {};
|
||||
const data = await company.create(companyPayload);
|
||||
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.');
|
||||
}
|
||||
|
||||
if (numbers) {
|
||||
try {
|
||||
const listRes = await numberApi.list();
|
||||
const serverNumbers = listRes?.numbers ?? [];
|
||||
for (const serverRow of serverNumbers) {
|
||||
const userConfig = numbers[serverRow.type];
|
||||
if (userConfig) {
|
||||
const prefix = String(userConfig.prefix ?? '');
|
||||
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||
}
|
||||
}
|
||||
} catch (numErr) {
|
||||
console.warn('Initial number sequences could not be saved:', numErr);
|
||||
}
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, incoming);
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region logout
|
||||
// MARK: calls auth.logout from ../api
|
||||
/**
|
||||
* Ends the server session, clears the current user, and returns to `/`.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logout() {
|
||||
await auth.logout();
|
||||
window.history.replaceState(null, '', '/');
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_LOGGED_OUT });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
/**
|
||||
* Global authentication state container.
|
||||
*
|
||||
* @type {Automat<AuthState>}
|
||||
*/
|
||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
|
||||
// #region selectors
|
||||
export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
|
||||
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
|
||||
export const selectSelectedCompanyId = () =>
|
||||
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
||||
export const selectRetryNotification = () => authAutomat.state.retryNotification;
|
||||
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
|
||||
export const selectVhost = () => Boolean(authAutomat.state.vhost);
|
||||
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
|
||||
|
||||
/**
|
||||
* Re-reads `GET /api/auth/me` from the server.
|
||||
*
|
||||
* Called after login, logout, and company switches so the automat reflects
|
||||
* the current server session rather than an optimistic guess.
|
||||
*
|
||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null, schemaVersion?: number|null}>} Session.
|
||||
*/
|
||||
export function bootstrapAuthMe() {
|
||||
return authAutomat.actions.bootstrapSession();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
export default authAutomat;
|
||||
229
src/automats/backupAutomat.js
Normal file
229
src/automats/backupAutomat.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @file backupAutomat.js
|
||||
* @description Manages backup status, archive listings, and VFS file recovery from backup archives (`GET /backup`, `GET /backup/:id/vfs`, `POST /backup/:id/vfs/recover`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
|
||||
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { backup as backupApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupItem
|
||||
* @property {string} name Archive file or directory name
|
||||
* @property {string} path Full storage path
|
||||
* @property {number} totalSizeBytes Total size in bytes
|
||||
* @property {string|null} timestamp Creation timestamp
|
||||
* @property {object|null} [manifest] Backup manifest details
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupStatus
|
||||
* @property {boolean} enabled Whether automated backups are enabled
|
||||
* @property {boolean} isRunning Whether a backup is currently executing
|
||||
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
|
||||
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
|
||||
* @property {string} backupDir Root directory where backups are stored
|
||||
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
|
||||
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
|
||||
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
|
||||
* @property {BackupItem|null} [latestDaily] Most recent daily backup
|
||||
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupState
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
||||
* @property {string|null} error Last error message
|
||||
* @property {BackupStatus|null} backupStatus Service status and retention settings
|
||||
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
|
||||
* @property {Record<string, Array<{ name: string, size: number, mime: string, mtime: string }>>} vfsFilesByBackup
|
||||
* @property {Record<string, boolean>} vfsLoadingByBackup
|
||||
*/
|
||||
|
||||
/** @type {BackupState} */
|
||||
const initialState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
backupStatus: null,
|
||||
backups: {
|
||||
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||
hourly: [],
|
||||
daily: [],
|
||||
monthly: [],
|
||||
},
|
||||
vfsFilesByBackup: {},
|
||||
vfsLoadingByBackup: {},
|
||||
};
|
||||
|
||||
let backupAutomat;
|
||||
let inflightLoad = null;
|
||||
|
||||
// #region fetch
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
async function fetchBackupData() {
|
||||
const data = await backupApi.getStatus();
|
||||
const status = data?.status ?? null;
|
||||
const rawBackups = data?.backups ?? {};
|
||||
|
||||
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
|
||||
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
|
||||
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
|
||||
|
||||
const counts = rawBackups.counts ?? {
|
||||
hourly: hourly.length,
|
||||
daily: daily.length,
|
||||
monthly: monthly.length,
|
||||
};
|
||||
|
||||
return {
|
||||
backupStatus: status,
|
||||
backups: {
|
||||
counts,
|
||||
hourly,
|
||||
daily,
|
||||
monthly,
|
||||
},
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const actions = {
|
||||
// #region load
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
/**
|
||||
* Loads backup status and archive listing.
|
||||
* Deduplicates concurrent callers.
|
||||
*
|
||||
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
|
||||
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
|
||||
*/
|
||||
load(silent = false) {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
if (!silent) {
|
||||
backupAutomat.setState({ status: 'loading', error: null });
|
||||
}
|
||||
inflightLoad = (async () => {
|
||||
try {
|
||||
const { backupStatus, backups } = await fetchBackupData();
|
||||
backupAutomat.setState({
|
||||
backupStatus,
|
||||
backups,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return { backupStatus, backups };
|
||||
} catch (err) {
|
||||
const error = err?.message ?? String(err);
|
||||
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||
throw err;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region reload
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
/**
|
||||
* Silently re-fetches backup status and archives in the background.
|
||||
*/
|
||||
async reload() {
|
||||
return actions.load(true);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region listBackupVfs
|
||||
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
|
||||
/**
|
||||
* Retrieves the list of VFS files stored inside a specific backup.
|
||||
*
|
||||
* @param {string} backupId Identifier of the backup
|
||||
* @param {number} [companyId] Optional company ID override
|
||||
* @returns {Promise<Array<{ name: string, size: number, mime: string, mtime: string }>>}
|
||||
*/
|
||||
async listBackupVfs(backupId, companyId) {
|
||||
if (!backupId) return [];
|
||||
backupAutomat.setState({
|
||||
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
|
||||
});
|
||||
try {
|
||||
const res = await backupApi.listVfs(backupId, companyId);
|
||||
const files = Array.isArray(res?.files) ? res.files : [];
|
||||
backupAutomat.setState({
|
||||
vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files },
|
||||
});
|
||||
return files;
|
||||
} finally {
|
||||
backupAutomat.setState({
|
||||
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false },
|
||||
});
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region recoverBackupVfs
|
||||
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
|
||||
/**
|
||||
* Restores a single VFS file from the specified backup into the active VFS.
|
||||
*
|
||||
* @param {string} backupId Identifier of the backup
|
||||
* @param {string} path Relative file path within the VFS
|
||||
* @param {number} [companyId] Optional company ID override
|
||||
* @returns {Promise<object>} Recovery result
|
||||
*/
|
||||
async recoverBackupVfs(backupId, path, companyId) {
|
||||
return backupApi.recoverVfs(backupId, path, companyId);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Resets the automat state to initial empty values.
|
||||
*/
|
||||
clear() {
|
||||
backupAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
|
||||
|
||||
// #region sseListener
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('backup', () => {
|
||||
void backupAutomat.actions.reload().catch(() => {});
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region authSync
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
backupAutomat.actions.clear();
|
||||
} else {
|
||||
void backupAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
// #endregion
|
||||
|
||||
export default backupAutomat;
|
||||
548
src/automats/categoryAutomat.js
Normal file
548
src/automats/categoryAutomat.js
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* @file categoryAutomat.js
|
||||
* @description Owns the hierarchical category tree (id -> node map), the root
|
||||
* category id, breadcrumbs helpers, and all category/article mutations.
|
||||
*
|
||||
* Per-category article views (`categoryViews`) live in `categoryViewAutomat.js`
|
||||
* and attach to this tree automat via `attachCategoryTree()`, keeping the
|
||||
* dependency one-directional (views -> tree).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `companyAutomat`: Subscribes to company changes — triggers `actions.loadTree()`
|
||||
* whenever `company.current.id` or `rootCategoryId` changes/becomes available;
|
||||
* resets when company is unset.
|
||||
* - `authAutomat`: Subscribes to auth state changes — triggers `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `categoryAutomat.actions.loadTree()` and
|
||||
* `invalidateAllCategoryViews()` upon receiving a `'category'` SSE event.
|
||||
* - UI / Routes: Category tree operations (`createCategory`, `createArticle`,
|
||||
* `deleteCategory`, `moveCategory`, `renameCategory`, `reorderCategories`, etc.).
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - `categoryViewAutomat`: `loadTree()` calls `loadAllCategoryViews()` once
|
||||
* `treeStatus === 'succeeded'`, so every category's articles are already cached
|
||||
* when a route mounts. Mutations patch the loaded views through the same module.
|
||||
* - `articleIndexAutomat`: Derives its client-side search index from the union of
|
||||
* the loaded category views, so the index costs no requests of its own and
|
||||
* `GET /api/articleIndex` is no longer used.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { category as categoryApi, article as articleApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
import {
|
||||
attachCategoryTree,
|
||||
categoryViews,
|
||||
loadAllCategoryViews,
|
||||
disposeAllCategoryViewAutomats,
|
||||
refreshCategoryViewChildren,
|
||||
appendArticlesToCategoryViews,
|
||||
updateArticleInCategoryViews,
|
||||
removeArticleBySku,
|
||||
waitForCategoryTree,
|
||||
} from './categoryViewAutomat';
|
||||
import {
|
||||
parseCategoryTreeResponse,
|
||||
normalizeCategoryNode,
|
||||
cleanCategorySlugPath,
|
||||
getCategoryChildren,
|
||||
getCategoryDescendantIds,
|
||||
} from '../utils/categoryUtils';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CategoryState
|
||||
* @property {number|string|null} companyId
|
||||
* @property {string|null} languageId
|
||||
* @property {Record<string,object>|null} categories id → node map
|
||||
* @property {number|string|null} rootCategoryId
|
||||
* @property {number} articleCount Total parent articles in tree
|
||||
* @property {boolean} renameMode
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
||||
* @property {string|null} error
|
||||
* @property {boolean} viewsLoading Whether the post-tree view preload is running.
|
||||
*/
|
||||
|
||||
/** @type {CategoryState} */
|
||||
const initialState = {
|
||||
companyId: null,
|
||||
languageId: null,
|
||||
categories: null,
|
||||
rootCategoryId: null,
|
||||
articleCount: 0,
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
error: null,
|
||||
viewsLoading: false,
|
||||
};
|
||||
|
||||
let categoryAutomat;
|
||||
let _treeLoadingPromise = null;
|
||||
|
||||
/**
|
||||
* Returns the parsed category map and root id, or `null` while the tree is
|
||||
* still loading. Replaces the former `StorageManager.getCategoryState()`.
|
||||
*
|
||||
* @returns {{ categories: Record<string,object>, rootCategoryId: unknown }|null}
|
||||
*/
|
||||
export function getCategoryState() {
|
||||
const state = categoryAutomat.state;
|
||||
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
|
||||
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
||||
}
|
||||
|
||||
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
|
||||
|
||||
/**
|
||||
* Resolves once the category tree has loaded.
|
||||
*
|
||||
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
|
||||
*/
|
||||
export function whenCategoryTreeReady() {
|
||||
return waitForCategoryTree();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const actions = {
|
||||
/** Load the category tree for the currently selected company. */
|
||||
async loadTree() {
|
||||
let company = companyAutomat.state.current;
|
||||
if (!company && companyAutomat.state.status === 'idle') {
|
||||
await companyAutomat.actions.load();
|
||||
company = companyAutomat.state.current;
|
||||
}
|
||||
if (!company) return;
|
||||
const { id: companyId, rootCategoryId: companyRootId } = company;
|
||||
if (!companyRootId) return;
|
||||
|
||||
if (_treeLoadingPromise) return _treeLoadingPromise;
|
||||
|
||||
categoryAutomat.setState({ treeStatus: 'loading', error: null });
|
||||
_treeLoadingPromise = (async () => {
|
||||
try {
|
||||
const data = await categoryApi.getTree();
|
||||
const parsed = parseCategoryTreeResponse(data, companyRootId);
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'succeeded',
|
||||
companyId,
|
||||
languageId: parsed.languageId,
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
articleCount: parsed.articleCount,
|
||||
error: null,
|
||||
viewsLoading: true,
|
||||
});
|
||||
refreshCategoryViewChildren(parsed.categories);
|
||||
|
||||
// The tree is the source of truth for every category view, so warm all
|
||||
// of them now: routes mount from cache instead of waiting on a fetch.
|
||||
// A failing category must not fail the whole tree load.
|
||||
const { failed } = await loadAllCategoryViews();
|
||||
if (failed.length) {
|
||||
console.warn(
|
||||
`${failed.length} Kategorie(n) konnten nicht vorab geladen werden.`,
|
||||
failed,
|
||||
);
|
||||
}
|
||||
categoryAutomat.setState({ viewsLoading: false });
|
||||
} catch (err) {
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'failed',
|
||||
viewsLoading: false,
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})().finally(() => {
|
||||
_treeLoadingPromise = null;
|
||||
});
|
||||
|
||||
return _treeLoadingPromise;
|
||||
},
|
||||
|
||||
/** Toggle rename/edit mode for the category grid. */
|
||||
setRenameMode(enabled) {
|
||||
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
||||
},
|
||||
|
||||
/**
|
||||
* (Re-)loads every category view of the current tree on demand.
|
||||
*
|
||||
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
|
||||
*/
|
||||
async loadAllCategoryViews() {
|
||||
return loadAllCategoryViews();
|
||||
},
|
||||
|
||||
// ─── Category mutations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a single category.
|
||||
* @param {{ parentId: number|string, name: string }} param0
|
||||
*/
|
||||
async createCategory({ parentId, name }) {
|
||||
const res = await categoryApi.create({ parentId, name });
|
||||
const node = normalizeCategoryNode(res.category);
|
||||
const { categories } = categoryAutomat.state;
|
||||
const updated = { ...(categories ?? {}), [String(node.id)]: node };
|
||||
categoryAutomat.setState({ categories: updated });
|
||||
refreshCategoryViewChildren(updated);
|
||||
return node;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create multiple categories under the same parent.
|
||||
* @param {{ parentId: number|string, names: string[] }} param0
|
||||
*/
|
||||
async createCategories({ parentId, names }) {
|
||||
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
|
||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||
const created = [];
|
||||
for (const name of list) {
|
||||
const res = await categoryApi.create({ parentId, name });
|
||||
created.push(normalizeCategoryNode(res.category));
|
||||
}
|
||||
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
|
||||
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
|
||||
categoryAutomat.setState({ categories: updated });
|
||||
refreshCategoryViewChildren(updated);
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a single article.
|
||||
* @param {{ parentId: number|string, name: string }} param0
|
||||
*/
|
||||
async createArticle({ parentId, name }) {
|
||||
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||
const { article: created } = await articleApi.getBySku(sku);
|
||||
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create multiple articles under the same parent.
|
||||
* @param {{ parentId: number|string, names: string[] }} param0
|
||||
*/
|
||||
async createArticles({ parentId, names }) {
|
||||
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
|
||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||
const created = [];
|
||||
for (const name of list) {
|
||||
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||
const { article: created1 } = await articleApi.getBySku(sku);
|
||||
if (created1) created.push(created1);
|
||||
}
|
||||
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a category.
|
||||
* @param {{ categoryId: number|string, name: string }} param0
|
||||
*/
|
||||
async renameCategory({ categoryId, name }) {
|
||||
if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) {
|
||||
throw new Error('Home-Kategorie kann nicht umbenannt werden.');
|
||||
}
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed) throw new Error('Name required');
|
||||
const res = await categoryApi.updateName(categoryId, trimmed);
|
||||
const updated = normalizeCategoryNode(res.category);
|
||||
const key = String(updated.id);
|
||||
const { categories } = categoryAutomat.state;
|
||||
const nextCategories = categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null;
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder sibling categories.
|
||||
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
|
||||
*/
|
||||
async reorderCategories({ parentId, categoryIds }) {
|
||||
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
|
||||
const res = await categoryApi.reorder(targetParentId, categoryIds);
|
||||
const ordered = res.categoryIds;
|
||||
const parentKey = String(targetParentId);
|
||||
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
|
||||
const patched = { ...categoryAutomat.state.categories };
|
||||
for (const node of Object.values(patched)) {
|
||||
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
|
||||
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
|
||||
}
|
||||
}
|
||||
categoryAutomat.setState({ categories: patched });
|
||||
refreshCategoryViewChildren(patched);
|
||||
return ordered;
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a category under a new parent.
|
||||
* @param {{ categoryId: number|string, parentId: number|string }} param0
|
||||
*/
|
||||
async moveCategory({ categoryId, parentId }) {
|
||||
const res = await categoryApi.move(categoryId, parentId);
|
||||
const moved = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories || !moved) return;
|
||||
const key = String(moved.id);
|
||||
const updated = {
|
||||
...categories,
|
||||
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
|
||||
};
|
||||
categoryAutomat.setState({
|
||||
categories: updated,
|
||||
});
|
||||
refreshCategoryViewChildren(updated);
|
||||
return moved;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a category by id.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async deleteCategory(categoryId) {
|
||||
await categoryApi.delete(categoryId);
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return;
|
||||
const next = { ...categories };
|
||||
delete next[String(categoryId)];
|
||||
categoryAutomat.setState({ categories: next });
|
||||
refreshCategoryViewChildren(next);
|
||||
return categoryId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a category and all its descendants recursively.
|
||||
* Calls DELETE /api/category/:id?recursive=true.
|
||||
*
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async deleteCategoryRecursive(categoryId) {
|
||||
const { categories, rootCategoryId } = categoryAutomat.state;
|
||||
|
||||
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
|
||||
throw new Error('Root-Kategorie kann nicht gelöscht werden');
|
||||
}
|
||||
|
||||
const removedIds = getCategoryDescendantIds(categories, categoryId);
|
||||
|
||||
await categoryApi.delete(categoryId, { recursive: true });
|
||||
|
||||
if (categories) {
|
||||
const next = { ...categories };
|
||||
for (const id of removedIds) {
|
||||
delete next[String(id)];
|
||||
}
|
||||
categoryAutomat.setState({ categories: next });
|
||||
refreshCategoryViewChildren(next);
|
||||
}
|
||||
|
||||
return categoryId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the POS flag on a category.
|
||||
* @param {{ categoryId: number|string, pos: boolean }} param0
|
||||
*/
|
||||
async toggleCategoryPos({ categoryId, pos }) {
|
||||
const res = await categoryApi.updatePos(categoryId, pos);
|
||||
const updated = normalizeCategoryNode(res.category);
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return;
|
||||
const key = String(updated.id);
|
||||
const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } };
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload an image for a category.
|
||||
* @param {{ categoryId: number|string, file: Blob }} param0
|
||||
*/
|
||||
async uploadCategoryImage({ categoryId, file }) {
|
||||
const res = await categoryApi.uploadImage(categoryId, file);
|
||||
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
const nextCategories = { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } };
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the image for a category.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async deleteCategoryImage(categoryId) {
|
||||
const res = await categoryApi.deleteImage(categoryId);
|
||||
const { id } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
const nextCategories = {
|
||||
...categories,
|
||||
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
|
||||
};
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a mosaic image for a category from its articles and subcategories.
|
||||
* @param {{ categoryId: number|string, size?: number }} param0
|
||||
*/
|
||||
async generateCategoryImage({ categoryId, size } = {}) {
|
||||
const res = await categoryApi.generateImage(categoryId, { size });
|
||||
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
const nextCategories = {
|
||||
...categories,
|
||||
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
|
||||
};
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
|
||||
// ─── Local state patches ───────────────────────────────────────────────────
|
||||
|
||||
/** Remove an article from all live category view lists (used after article deletion). */
|
||||
removeArticleBySku(sku) {
|
||||
removeArticleBySku(sku);
|
||||
},
|
||||
|
||||
/**
|
||||
* Patch posterminalIds on a category node.
|
||||
* @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0
|
||||
*/
|
||||
patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) {
|
||||
const { categories } = categoryAutomat.state;
|
||||
const cat = categories?.[String(categoryId)];
|
||||
if (!cat) return;
|
||||
const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : [];
|
||||
const next = enabled
|
||||
? current.includes(terminalId) ? current : [...current, terminalId]
|
||||
: current.filter((id) => id !== terminalId);
|
||||
const nextCategories = {
|
||||
...categories,
|
||||
[String(categoryId)]: { ...cat, posterminalIds: next },
|
||||
};
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
},
|
||||
|
||||
/** Invalidate all loaded category views. */
|
||||
invalidateAllCategoryViews() {
|
||||
invalidateAllCategoryViews();
|
||||
},
|
||||
|
||||
/** Reset state to idle (logout / company switch). */
|
||||
reset() {
|
||||
disposeAllCategoryViewAutomats();
|
||||
_treeLoadingPromise = null;
|
||||
categoryAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global category tree state container.
|
||||
* Loads automatically when companyAutomat has a company with a rootCategoryId.
|
||||
*
|
||||
* @type {Automat<CategoryState>}
|
||||
*/
|
||||
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
|
||||
|
||||
// Let the per-category views resolve their slug paths against this tree.
|
||||
attachCategoryTree(categoryAutomat);
|
||||
|
||||
// Keyed view accessors on categoryAutomat:
|
||||
categoryAutomat.key = (slugPath) => categoryViews.key(cleanCategorySlugPath(slugPath));
|
||||
categoryAutomat.read = (slugPath) => categoryViews.read(cleanCategorySlugPath(slugPath));
|
||||
categoryAutomat.load = (slugPath, options) => categoryViews.load(cleanCategorySlugPath(slugPath), options);
|
||||
categoryAutomat.views = categoryViews;
|
||||
|
||||
// Load tree whenever the company data changes (or first becomes available)
|
||||
let _lastCompanyId = null;
|
||||
function syncCategoryWithCompany(company) {
|
||||
const id = company?.current?.id ?? null;
|
||||
const rootCategoryId = company?.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
const isCompanySwitch = _lastCompanyId !== null;
|
||||
_lastCompanyId = id;
|
||||
if (isCompanySwitch) {
|
||||
disposeAllCategoryViewAutomats();
|
||||
}
|
||||
void categoryAutomat.actions.loadTree();
|
||||
}
|
||||
if (!id && _lastCompanyId) {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
}
|
||||
|
||||
companyAutomat.subscribe(syncCategoryWithCompany);
|
||||
syncCategoryWithCompany(companyAutomat.state);
|
||||
|
||||
// Reset tree on logout
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch(() => {});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export pure helpers for consumers that previously imported from categorySlice
|
||||
export {
|
||||
ROOT_CATEGORY_NAME,
|
||||
parseCategoryTreeResponse,
|
||||
normalizeCategoryNode,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
cleanCategorySlugPath,
|
||||
resolveCategoryIdFromSlugPath,
|
||||
} from '../utils/categoryUtils';
|
||||
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
|
||||
|
||||
// Re-export category view automats and actions
|
||||
export {
|
||||
categoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
invalidateAllCategoryViews,
|
||||
disposeAllCategoryViewAutomats,
|
||||
loadAllCategoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
updateArticleInCategoryViews,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
export default categoryAutomat;
|
||||
413
src/automats/categoryViewAutomat.js
Normal file
413
src/automats/categoryViewAutomat.js
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* @file categoryViewAutomat.js
|
||||
* @description Owns the per-category view automats (`categoryViews`), a
|
||||
* `KeyedAutomat` keyed by category slug path. Each entry holds one category's
|
||||
* articles (`GET /api/category/:id/articles`) plus its child categories, and is
|
||||
* resolved from the category slug path against the loaded category tree.
|
||||
*
|
||||
* This file holds ONLY the keyed per-category views. The category tree itself
|
||||
* (`categories` map, `rootCategoryId`, category/article mutations) lives in
|
||||
* `categoryAutomat.js`; this module receives it through `attachCategoryTree()`
|
||||
* so the dependency stays one-directional (views → tree).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `categoryAutomat`: The tree automat attaches itself via `attachCategoryTree()`
|
||||
* and calls `loadAllCategoryViews()` once `treeStatus === 'succeeded'`,
|
||||
* `reloadLoadedCategoryViews()` when the tree changes, and
|
||||
* `disposeAllCategoryViewAutomats()` on `reset()` (logout / company switch).
|
||||
* - `sseAutomat`: Calls `invalidateAllCategoryViews()` on a `'category'` event and
|
||||
* on an `'articles'` event for the open views.
|
||||
* - UI / Routes: `categoryAutomat.key(slugPath)` /
|
||||
* `categoryAutomat.read(slugPath)` / `categoryAutomat.load(slugPath)` facades.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - Routes / components: `src/routes/artikel/kategorien.jsx` (grid + drag & drop),
|
||||
* `src/components/category/CategoryTree.jsx`.
|
||||
*/
|
||||
|
||||
import { KeyedAutomat } from 'react-automat';
|
||||
import { category as categoryApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import {
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
cleanCategorySlugPath,
|
||||
resolveCategoryIdFromSlugPath,
|
||||
} from '../utils/categoryUtils';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CategoryViewData
|
||||
* @property {string} slug Cleaned slug path this view was keyed by.
|
||||
* @property {number|string} categoryId Resolved category id.
|
||||
* @property {Array<object>} articles Articles in that category.
|
||||
* @property {Array<object>} childCategories Direct child category nodes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The tree automat, injected by `categoryAutomat.js` at module-evaluation time.
|
||||
* @type {import('react-automat').Automat<object>|null}
|
||||
*/
|
||||
let treeAutomat = null;
|
||||
|
||||
/**
|
||||
* Injects the category tree automat this module resolves slug paths against.
|
||||
*
|
||||
* @param {import('react-automat').Automat<object>} automat Tree automat.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function attachCategoryTree(automat) {
|
||||
treeAutomat = automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attached tree automat.
|
||||
*
|
||||
* @returns {import('react-automat').Automat<object>} Tree automat.
|
||||
* @throws {Error} When the tree automat has not been attached yet.
|
||||
*/
|
||||
function tree() {
|
||||
if (!treeAutomat) {
|
||||
throw new Error('categoryAutomat has not attached the category tree yet.');
|
||||
}
|
||||
return treeAutomat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the attached category tree has loaded successfully.
|
||||
*
|
||||
* Rejects when the tree load fails or the session becomes anonymous, so callers
|
||||
* waiting on a view never hang forever.
|
||||
*
|
||||
* @returns {Promise<object>} Tree state with `treeStatus === 'succeeded'`.
|
||||
*/
|
||||
export function waitForCategoryTree() {
|
||||
const current = tree().state;
|
||||
if (current.treeStatus === 'succeeded') return Promise.resolve(current);
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsubTree = null;
|
||||
let unsubAuth = null;
|
||||
const cleanup = () => {
|
||||
unsubTree?.();
|
||||
unsubAuth?.();
|
||||
unsubTree = null;
|
||||
unsubAuth = null;
|
||||
};
|
||||
unsubTree = tree().subscribe((state) => {
|
||||
if (state.treeStatus === 'succeeded') {
|
||||
cleanup();
|
||||
resolve(state);
|
||||
} else if (state.treeStatus === 'failed') {
|
||||
cleanup();
|
||||
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
|
||||
}
|
||||
});
|
||||
unsubAuth = authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
cleanup();
|
||||
reject(new Error('Nicht authentifiziert.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route category view stores, keyed by cleaned category slug path.
|
||||
*
|
||||
* @type {KeyedAutomat<CategoryViewData>}
|
||||
*/
|
||||
export const categoryViews = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'category-views',
|
||||
loader: async (slugPath) => {
|
||||
const slug = cleanCategorySlugPath(slugPath);
|
||||
|
||||
// The tree must be loaded before a slug path can be resolved to an id.
|
||||
const treeState = tree().state;
|
||||
if (treeState.treeStatus !== 'succeeded') {
|
||||
if (treeState.treeStatus === 'idle') {
|
||||
void tree().actions.loadTree();
|
||||
}
|
||||
await waitForCategoryTree();
|
||||
}
|
||||
|
||||
const { categories, rootCategoryId } = tree().state;
|
||||
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
|
||||
if (categoryId == null) {
|
||||
throw new Error(`Kategorie für Pfad „${slug}“ nicht gefunden.`);
|
||||
}
|
||||
|
||||
const res = await categoryApi.getArticles(categoryId);
|
||||
const articles = Array.isArray(res.articles) ? res.articles : [];
|
||||
const childCategories = getCategoryChildren(categories, categoryId);
|
||||
|
||||
return { slug, categoryId, articles, childCategories };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Runs `worker` over `items` with a bounded number of concurrent calls.
|
||||
* Failures are captured per item instead of rejecting the whole batch.
|
||||
*
|
||||
* @template T, R
|
||||
* @param {Array<T>} items Work items.
|
||||
* @param {number} limit Maximum concurrent workers.
|
||||
* @param {(item: T, index: number) => Promise<R>} worker Async work per item.
|
||||
* @returns {Promise<Array<{ok: true, value: R}|{ok: false, error: any}>>} Ordered results.
|
||||
*/
|
||||
async function mapWithConcurrency(items, limit, worker) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
|
||||
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
try {
|
||||
results[index] = { ok: true, value: await worker(items[index], index) };
|
||||
} catch (error) {
|
||||
results[index] = { ok: false, error };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the slug path for a category from its breadcrumb trail.
|
||||
*
|
||||
* @param {Record<string,object>} categories id → node map.
|
||||
* @param {number|string} rootCategoryId Root category id.
|
||||
* @param {number|string} categoryId Category id.
|
||||
* @returns {string} Slug path usable as a `categoryViews` key.
|
||||
*/
|
||||
export function categoryViewSlugPath(categories, rootCategoryId, categoryId) {
|
||||
return getCategoryBreadcrumbs(categories, categoryId, rootCategoryId)
|
||||
.map((crumb) => crumb.slug)
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the data of every currently loaded category view.
|
||||
*
|
||||
* Used by `articleIndexAutomat` to derive the search index from the view cache
|
||||
* without issuing additional requests.
|
||||
*
|
||||
* @returns {Array<CategoryViewData>} Loaded view payloads.
|
||||
*/
|
||||
export function collectLoadedCategoryViewData() {
|
||||
const out = [];
|
||||
for (const item of Object.values(categoryViews.state)) {
|
||||
if (item?.loaded && item.data) out.push(item.data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a view for **every** category in the loaded tree.
|
||||
*
|
||||
* Called by `categoryAutomat` once the tree finished loading, so navigating to
|
||||
* any category (and the derived article index) resolves from cache instead of
|
||||
* waiting for a round-trip. Already-loaded views are skipped.
|
||||
*
|
||||
* @param {{ concurrency?: number }} [options] Load tuning.
|
||||
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
|
||||
*/
|
||||
export async function loadAllCategoryViews({ concurrency = 6 } = {}) {
|
||||
const treeState = tree().state;
|
||||
if (treeState.treeStatus !== 'succeeded') {
|
||||
return { loaded: 0, failed: [] };
|
||||
}
|
||||
|
||||
const { categories, rootCategoryId } = treeState;
|
||||
const slugs = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const node of Object.values(categories ?? {})) {
|
||||
const slug = categoryViewSlugPath(categories, rootCategoryId, node.id);
|
||||
if (!slug || seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
slugs.push(slug);
|
||||
}
|
||||
|
||||
const results = await mapWithConcurrency(slugs, concurrency, (slug) =>
|
||||
// Force only when not already cached; `load()` resolves immediately otherwise.
|
||||
categoryViews.load(slug),
|
||||
);
|
||||
|
||||
const failed = [];
|
||||
let loaded = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.ok) loaded += 1;
|
||||
else failed.push({ slug: slugs[index], error: result.error });
|
||||
});
|
||||
|
||||
return { loaded, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches only the views that are already cached, keeping the set of cached
|
||||
* categories small. Used for SSE-driven refreshes.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves once all cached views settled.
|
||||
*/
|
||||
export async function reloadLoadedCategoryViews() {
|
||||
await categoryViews.setDirty(undefined, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-invalidates all loaded category views.
|
||||
*
|
||||
* @returns {Promise<unknown[]>} Per-view reload promises.
|
||||
*/
|
||||
export function invalidateAllCategoryViews() {
|
||||
return categoryViews.setDirty(undefined, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes every category view (logout / company switch).
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function disposeAllCategoryViewAutomats() {
|
||||
categoryViews.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the view handle for a slug path (backward compatible helper).
|
||||
*
|
||||
* @param {{ slugPath: string }} param0
|
||||
* @returns {import('react-automat').KeyHandle<CategoryViewData>} Keyed view handle.
|
||||
*/
|
||||
export function getOrCreateCategoryAutomat({ slugPath }) {
|
||||
return categoryViews.key(cleanCategorySlugPath(slugPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives `childCategories` for every loaded view after the tree changed.
|
||||
*
|
||||
* @param {Record<string,object>|null} categories Fresh id → node map.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function refreshCategoryViewChildren(categories) {
|
||||
if (!categories) return;
|
||||
for (const [slug, item] of categoryViews._items.entries()) {
|
||||
if (item.data && item.data.categoryId != null) {
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
childCategories: getCategoryChildren(categories, item.data.categoryId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends newly created articles to every loaded view of `categoryId`.
|
||||
*
|
||||
* @param {{ categoryId: number|string, articles: Array<object> }} param0
|
||||
* @returns {void}
|
||||
*/
|
||||
export function appendArticlesToCategoryViews({ categoryId, articles }) {
|
||||
if (!articles?.length || categoryId == null) return;
|
||||
for (const [slug, item] of categoryViews._items.entries()) {
|
||||
if (item.data && String(item.data.categoryId) === String(categoryId)) {
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: [...(item.data.articles || []), ...articles],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an article in all loaded category views.
|
||||
*
|
||||
* @param {object} article Article detail or row with at least `sku`.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function updateArticleInCategoryViews(article) {
|
||||
if (!article || article.sku == null) return;
|
||||
const sku = String(article.sku);
|
||||
const targetCategoryIds = Array.isArray(article.categoryIds)
|
||||
? new Set(article.categoryIds.map(String))
|
||||
: null;
|
||||
|
||||
for (const [slug, item] of categoryViews._items.entries()) {
|
||||
if (!item?.data) continue;
|
||||
const viewCatId = String(item.data.categoryId);
|
||||
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
|
||||
// Article moved out of this category
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
|
||||
});
|
||||
} else {
|
||||
// Update in-place
|
||||
const existing = item.data.articles[existingIndex];
|
||||
const updatedArticles = [...item.data.articles];
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const hasPhotosProp = 'photos' in article;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||
|
||||
updatedArticles[existingIndex] = {
|
||||
...existing,
|
||||
...article,
|
||||
name: article.name ?? existing.name,
|
||||
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
|
||||
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
|
||||
stock: article.stock ?? existing.stock,
|
||||
available: article.available ?? existing.available,
|
||||
warehouses: article.warehouses ?? existing.warehouses,
|
||||
brand: article.brand ?? existing.brand,
|
||||
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||
childCount: article.childCount ?? existing.childCount,
|
||||
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: updatedArticles,
|
||||
});
|
||||
}
|
||||
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
|
||||
// Article added to this category
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: [...(item.data.articles || []), article],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a deleted article from every loaded view that contains it.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function removeArticleBySku(sku) {
|
||||
if (!sku) return;
|
||||
for (const [slug, item] of categoryViews._items.entries()) {
|
||||
if (item.data?.articles?.some((r) => r.sku === sku)) {
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: item.data.articles.filter((r) => r.sku !== sku),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default categoryViews;
|
||||
1616
src/automats/chatAutomat.js
Normal file
1616
src/automats/chatAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
2040
src/automats/companyAutomat.js
Normal file
2040
src/automats/companyAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user