This commit is contained in:
seb
2026-09-23 17:24:33 +02:00
parent 0391e845f0
commit b331f8f535
17 changed files with 2352 additions and 202 deletions

View File

@@ -90,13 +90,13 @@ server {
proxy_pass http://127.0.0.1:4208; proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1; proxy_http_version 1.1;
# Clear Connection & Accept-Encoding to prevent upstream gzip/chunk buffering # Clear Connection & Accept-Encoding to prevent upstream buffering
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_set_header Accept-Encoding ""; proxy_set_header Accept-Encoding "";
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering and compression completely for immediate push # Disable buffering and compression completely for immediate push
proxy_buffering off; proxy_buffering off;
@@ -104,7 +104,8 @@ server {
chunked_transfer_encoding off; chunked_transfer_encoding off;
gzip off; gzip off;
# Prevent premature disconnections; keep SSE open for 24 hours # Disable HTTP/2 request limits for persistent SSE connection
# (Prevents NGINX from resetting long-lived streams)
proxy_read_timeout 86400s; proxy_read_timeout 86400s;
proxy_send_timeout 86400s; proxy_send_timeout 86400s;
} }

86
docs/README.md Normal file
View File

@@ -0,0 +1,86 @@
# 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.
---
## 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), route view article automats, tree ordering, drag-and-drop moves, and image uploads. |
| **`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.

196
docs/artikelAutomat.md Normal file
View 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
View 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
View 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).

278
docs/categoryAutomat.md Normal file
View File

@@ -0,0 +1,278 @@
# `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')`)
---
## 1. Overview & Purpose
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images, POS category visibility flags, and dynamic per-route category article view automats.
### Core Responsibilities
- **Hierarchical Category Tree**: Normalized `id → node` dictionary (`categories`) representing the parent-child relational tree.
- **Dynamic Per-Route Category View Automats**: Instantiates lightweight route-scoped automats (`category-view:<slug>`) that load category articles on-demand with automatic cache cleanup and disposal.
- **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 active route automat's article list.
- **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;
}
```
### Initial State
```js
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
renameMode: false,
treeStatus: 'idle',
error: null,
};
```
---
## 3. Dynamic Category View Automats (`KeyedAutomat`)
Category views are powered by a `KeyedAutomat` instance (`categoryViews`) indexed by route **slug path** (e.g. `''` for root, `'getraenke'`, `'getraenke/softdrinks'`).
### `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` (all child categories having the current as their root/parent).
- **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.
### `invalidateAllCategoryViews(): void`
Triggers `reload()` across all active slug view handles.
### `disposeAllCategoryViewAutomats(): void`
Clears all active view handles and frees memory on company switch or logout.
---
## 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, and updates state.
- **`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.

179
docs/companyAutomat.md Normal file
View 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()`.

View 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
View 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
View 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
View 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
View 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
View 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()`.

View File

@@ -1,4 +1,4 @@
import { Automat } from 'react-automat'; import { Automat, KeyedAutomat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api'; import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat'; import companyAutomat from './companyAutomat';
@@ -7,6 +7,8 @@ import {
normalizeCategoryNode, normalizeCategoryNode,
getCategoryBreadcrumbs, getCategoryBreadcrumbs,
getCategoryChildren, getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME, ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils'; } from '../utils/categoryUtils';
@@ -34,110 +36,81 @@ const initialState = {
let categoryAutomat; let categoryAutomat;
// Registry of active per-route category view automats /**
const categoryViewInstances = new Set(); * KeyedAutomat holding per-route category views indexed by slug path.
// Reverse index for O(1) parentId lookup to target route automat * For each slug path, loader resolves the categoryId from the category tree,
const categoryIdToRouteName = new Map(); * fetches articles in that category, and provides childCategories (children of the category).
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// Ensure tree is loaded before resolving slug -> categoryId
if (categoryAutomat.state.treeStatus !== 'succeeded') {
await categoryAutomat.actions.loadTree();
}
const { categories, rootCategoryId } = categoryAutomat.state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug || ROOT_CATEGORY_NAME}“ 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,
};
},
}
);
/** /**
* Loader for category view automat: fetches articles for categoryId. * Updates childCategories on all loaded category view handles when categories tree changes.
* Promise only resolves when all articles are loaded.
*/ */
async function fetchCategoryArticles(automat) { function refreshLoadedCategoryViewChildren(categories) {
try { if (!categories) return;
const res = await categoryApi.getArticles(automat.state.categoryId); for (const [slug, item] of categoryViews._items.entries()) {
const articles = Array.isArray(res.articles) ? res.articles : []; if (item.data && item.data.categoryId != null) {
return { articles, loaded: true, loading: false, error: null }; const childCategories = getCategoryChildren(categories, item.data.categoryId);
} catch (err) { categoryViews.key(slug).set({
const errorMsg = err?.message ?? String(err); ...item.data,
automat.setState({ loading: false, loaded: false, error: errorMsg }); childCategories,
throw err; });
}
} }
} }
/** /**
* Get or create a per-route category view automat for the given slug and categoryId. * Get or create a per-route category view automat for the given slug and categoryId.
* Returns null if categoryId is null. * (Backward compatibility helper)
*
* @param {{ slugPath?: string, categoryId: number|string|null }} param0
* @returns {Automat|null}
*/ */
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) { export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
if (categoryId == null) return null; const slug = cleanCategorySlugPath(slugPath);
return categoryViews.key(slug);
const cleanSlug = (slugPath ?? '').replace(/^\/+|\/+$/g, '');
const name = cleanSlug === '' ? 'category-view:root' : `category-view:${cleanSlug}`;
const existing = Automat.get(name);
if (existing) {
if (String(existing.state?.categoryId) === String(categoryId)) {
categoryViewInstances.add(existing);
categoryIdToRouteName.set(String(categoryId), name);
return existing;
}
// Stale category ID guard: ID changed, dispose old automat
try {
existing.dispose();
} catch {}
categoryViewInstances.delete(existing);
}
const automat = new Automat(
{
slug: cleanSlug,
categoryId,
articles: [],
loaded: false,
loading: true,
error: null,
},
(inst) => ({
reload: () => inst.setDirty({ keep: true, now: true }),
retry: () => inst.setDirty({ keep: false, now: true }),
}),
{
name,
loader: fetchCategoryArticles,
}
);
automat.reload = () => automat.setDirty({ keep: true, now: true });
automat.retry = () => automat.setDirty({ keep: false, now: true });
categoryViewInstances.add(automat);
categoryIdToRouteName.set(String(categoryId), name);
return automat;
} }
/** /**
* Invalidate all active category view automats by triggering background reload. * Invalidate all active category view automats by triggering background reload.
*/ */
export function invalidateAllCategoryViews() { export function invalidateAllCategoryViews() {
for (const automat of categoryViewInstances) { return categoryViews.setDirty(undefined, false, true);
if (typeof automat.reload === 'function') {
automat.reload().catch((err) => {
console.error('[categoryAutomat] reload view failed', err);
});
} else if (typeof automat.actions?.reload === 'function') {
automat.actions.reload().catch((err) => {
console.error('[categoryAutomat] reload view failed', err);
});
}
}
} }
/** /**
* Dispose all category view automats and clear registries (e.g. on logout or company switch). * Dispose all category view automats and clear registries (e.g. on logout or company switch).
*/ */
export function disposeAllCategoryViewAutomats() { export function disposeAllCategoryViewAutomats() {
for (const automat of categoryViewInstances) { categoryViews.clear();
try {
automat.dispose();
} catch (e) {
console.error('[categoryAutomat] dispose view failed', e);
}
}
categoryViewInstances.clear();
categoryIdToRouteName.clear();
} }
// ─── Actions ─────────────────────────────────────────────────────────────────── // ─── Actions ───────────────────────────────────────────────────────────────────
@@ -162,6 +135,7 @@ const actions = {
rootCategoryId: parsed.rootCategoryId, rootCategoryId: parsed.rootCategoryId,
error: null, error: null,
}); });
refreshLoadedCategoryViewChildren(parsed.categories);
} catch (err) { } catch (err) {
categoryAutomat.setState({ categoryAutomat.setState({
treeStatus: 'failed', treeStatus: 'failed',
@@ -186,9 +160,9 @@ const actions = {
const res = await categoryApi.create({ parentId, name }); const res = await categoryApi.create({ parentId, name });
const node = normalizeCategoryNode(res.category); const node = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
categoryAutomat.setState({ const updated = { ...(categories ?? {}), [String(node.id)]: node };
categories: { ...(categories ?? {}), [String(node.id)]: node }, categoryAutomat.setState({ categories: updated });
}); refreshLoadedCategoryViewChildren(updated);
return node; return node;
}, },
@@ -205,9 +179,9 @@ const actions = {
created.push(normalizeCategoryNode(res.category)); created.push(normalizeCategoryNode(res.category));
} }
const patch = Object.fromEntries(created.map((n) => [String(n.id), n])); const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
categoryAutomat.setState({ const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch }, categoryAutomat.setState({ categories: updated });
}); refreshLoadedCategoryViewChildren(updated);
return created; return created;
}, },
@@ -219,12 +193,11 @@ const actions = {
const res = await articleApi.create({ parentId, name }); const res = await articleApi.create({ parentId, name });
const created = res.article; const created = res.article;
if (created && parentId != null) { if (created && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId)); for (const [slug, item] of categoryViews._items.entries()) {
if (routeName) { if (item.data && String(item.data.categoryId) === String(parentId)) {
const routeAutomat = Automat.get(routeName); categoryViews.key(slug).set({
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) { ...item.data,
routeAutomat.setState({ articles: [...(item.data.articles || []), created],
articles: [...routeAutomat.state.articles, created],
}); });
} }
} }
@@ -245,12 +218,11 @@ const actions = {
if (res.article) created.push(res.article); if (res.article) created.push(res.article);
} }
if (created.length > 0 && parentId != null) { if (created.length > 0 && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId)); for (const [slug, item] of categoryViews._items.entries()) {
if (routeName) { if (item.data && String(item.data.categoryId) === String(parentId)) {
const routeAutomat = Automat.get(routeName); categoryViews.key(slug).set({
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) { ...item.data,
routeAutomat.setState({ articles: [...(item.data.articles || []), ...created],
articles: [...routeAutomat.state.articles, ...created],
}); });
} }
} }
@@ -272,9 +244,11 @@ const actions = {
const updated = normalizeCategoryNode(res.category); const updated = normalizeCategoryNode(res.category);
const key = String(updated.id); const key = String(updated.id);
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
const nextCategories = categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null;
categoryAutomat.setState({ categoryAutomat.setState({
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null, categories: nextCategories,
}); });
refreshLoadedCategoryViewChildren(nextCategories);
return updated; return updated;
}, },
@@ -295,6 +269,7 @@ const actions = {
} }
} }
categoryAutomat.setState({ categories: patched }); categoryAutomat.setState({ categories: patched });
refreshLoadedCategoryViewChildren(patched);
return ordered; return ordered;
}, },
@@ -308,12 +283,14 @@ const actions = {
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
if (!categories || !moved) return; if (!categories || !moved) return;
const key = String(moved.id); const key = String(moved.id);
const updated = {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
};
categoryAutomat.setState({ categoryAutomat.setState({
categories: { categories: updated,
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
},
}); });
refreshLoadedCategoryViewChildren(updated);
return moved; return moved;
}, },
@@ -328,6 +305,7 @@ const actions = {
const next = { ...categories }; const next = { ...categories };
delete next[String(categoryId)]; delete next[String(categoryId)];
categoryAutomat.setState({ categories: next }); categoryAutomat.setState({ categories: next });
refreshLoadedCategoryViewChildren(next);
return categoryId; return categoryId;
}, },
@@ -341,9 +319,11 @@ const actions = {
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
if (!categories) return; if (!categories) return;
const key = String(updated.id); const key = String(updated.id);
const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } };
categoryAutomat.setState({ categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } }, categories: nextCategories,
}); });
refreshLoadedCategoryViewChildren(nextCategories);
return updated; return updated;
}, },
@@ -357,9 +337,11 @@ const actions = {
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
if (!categories) return res.category; if (!categories) return res.category;
const key = String(id); const key = String(id);
const nextCategories = { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } };
categoryAutomat.setState({ categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } }, categories: nextCategories,
}); });
refreshLoadedCategoryViewChildren(nextCategories);
return res.category; return res.category;
}, },
@@ -373,12 +355,14 @@ const actions = {
const { categories } = categoryAutomat.state; const { categories } = categoryAutomat.state;
if (!categories) return res.category; if (!categories) return res.category;
const key = String(id); const key = String(id);
const nextCategories = {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
};
categoryAutomat.setState({ categoryAutomat.setState({
categories: { categories: nextCategories,
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
},
}); });
refreshLoadedCategoryViewChildren(nextCategories);
return res.category; return res.category;
}, },
@@ -387,11 +371,11 @@ const actions = {
/** Remove an article from all live category view lists (used after article deletion). */ /** Remove an article from all live category view lists (used after article deletion). */
removeArticleBySku(sku) { removeArticleBySku(sku) {
if (!sku) return; if (!sku) return;
for (const automat of categoryViewInstances) { for (const [slug, item] of categoryViews._items.entries()) {
const articles = automat.state?.articles; if (item.data?.articles?.some((r) => r.sku === sku)) {
if (Array.isArray(articles) && articles.some((r) => r.sku === sku)) { categoryViews.key(slug).set({
automat.setState({ ...item.data,
articles: articles.filter((r) => r.sku !== sku), articles: item.data.articles.filter((r) => r.sku !== sku),
}); });
} }
} }
@@ -409,12 +393,14 @@ const actions = {
const next = enabled const next = enabled
? current.includes(terminalId) ? current : [...current, terminalId] ? current.includes(terminalId) ? current : [...current, terminalId]
: current.filter((id) => id !== terminalId); : current.filter((id) => id !== terminalId);
const nextCategories = {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
};
categoryAutomat.setState({ categoryAutomat.setState({
categories: { categories: nextCategories,
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
},
}); });
refreshLoadedCategoryViewChildren(nextCategories);
}, },
/** Reset state to idle (logout / company switch). */ /** Reset state to idle (logout / company switch). */
@@ -432,6 +418,12 @@ const actions = {
*/ */
categoryAutomat = new Automat(initialState, actions, { name: 'category' }); categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// 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) // Load tree whenever the company data changes (or first becomes available)
let _lastCompanyId = null; let _lastCompanyId = null;
companyAutomat.subscribe((company) => { companyAutomat.subscribe((company) => {
@@ -463,6 +455,8 @@ export {
normalizeCategoryNode, normalizeCategoryNode,
getCategoryBreadcrumbs, getCategoryBreadcrumbs,
getCategoryChildren, getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
} from '../utils/categoryUtils'; } from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils'; export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';

View File

@@ -32,35 +32,10 @@ export function buildCategoryPath(categories, rootCategoryId, categoryId) {
return categoryPathFromCrumbs(crumbs); return categoryPathFromCrumbs(crumbs);
} }
export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) { export {
if (!categories || rootCategoryId == null) return null; resolveCategoryIdFromSlugPath,
cleanCategorySlugPath,
let segments = (slugPath ?? '') } from '../../utils/categoryUtils';
.split('/')
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
// Legacy slug URLs: .../Home/...
if (segments[0] === ROOT_CATEGORY_NAME) {
segments = segments.slice(1);
}
if (segments.length === 0) {
return rootCategoryId;
}
let currentId = rootCategoryId;
for (const slug of segments) {
const children = getCategoryChildren(categories, currentId);
const match = children.find(
(child) => categorySlug(child, rootCategoryId) === slug,
);
if (!match) return null;
currentId = match.id;
}
return currentId;
}
export function defaultCategoryPath() { export function defaultCategoryPath() {
return KATEGORIEN_BASE; return KATEGORIEN_BASE;

View File

@@ -25,7 +25,7 @@ import {
resolveCategoryIdFromSlugPath, resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME, ROOT_CATEGORY_NAME,
} from './categoryRoute'; } from './categoryRoute';
import categoryAutomat, { getOrCreateCategoryAutomat } from '../../automats/categoryAutomat'; import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat'; import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku } from '../../automats';
@@ -73,18 +73,19 @@ class ArtikelKategorienPage extends PureComponent {
); );
} }
this.routeAutomat = resolvedCategoryId != null this.handle = categoryAutomat.key(props.categorySlugPath ?? '');
? getOrCreateCategoryAutomat({ slugPath: props.categorySlugPath, categoryId: resolvedCategoryId }) const view = this.handle?.state;
: null; const catId = view?.data?.categoryId ?? resolvedCategoryId;
this.state = { this.state = {
drafts: {}, drafts: {},
...catState, ...catState,
resolvedCategoryId, resolvedCategoryId: catId,
articles: this.routeAutomat?.state?.articles ?? [], articles: view?.data?.articles ?? [],
loaded: this.routeAutomat?.state?.loaded ?? false, childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loading: this.routeAutomat?.state?.loading ?? false, loaded: view?.loaded ?? false,
routeError: this.routeAutomat?.state?.error ?? null, loading: view?.loading ?? false,
routeError: view?.error ?? null,
...selectTerminals(posterminalAutomat.state), ...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null, companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null, moveCategory: null,
@@ -104,8 +105,7 @@ class ArtikelKategorienPage extends PureComponent {
})); }));
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.ensurePosterminalsLoaded(); this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl(); this.subscribeViewHandle();
this.subscribeRouteAutomat();
this.syncSortable(); this.syncSortable();
} }
@@ -135,20 +135,27 @@ class ArtikelKategorienPage extends PureComponent {
this._unsubCat?.(); this._unsubCat?.();
this._unsubTerm?.(); this._unsubTerm?.();
this._unsubCompany?.(); this._unsubCompany?.();
this._unsubRoute?.(); this._unsubHandle?.();
this.sortableModule?.destroy(); this.sortableModule?.destroy();
} }
subscribeRouteAutomat() { subscribeViewHandle() {
this._unsubRoute?.(); this._unsubHandle?.();
this._unsubRoute = null; this._unsubHandle = null;
if (this.routeAutomat) { if (this.handle) {
this._unsubRoute = this.routeAutomat.subscribe(this, (s) => ({ this._unsubHandle = this.handle.subscribe(this, (item) => {
articles: s.articles, const catId = item.data?.categoryId ?? this.state.resolvedCategoryId;
loaded: s.loaded, const categories = this.state.categories;
loading: s.loading, return {
routeError: s.error, resolvedCategoryId: catId,
})); articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: item.loaded,
loading: item.loading,
routeError: item.error,
};
});
void this.handle.load().catch(() => {});
} }
} }
@@ -217,23 +224,25 @@ class ArtikelKategorienPage extends PureComponent {
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true }); navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
} }
const cleanSlug = (categorySlugPath ?? '').replace(/^\/+|\/+$/g, ''); const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
const currentRouteSlug = this.routeAutomat?.state?.slug; if (this.handle !== nextHandle) {
this.handle = nextHandle;
if (resolvedId !== resolvedCategoryId || cleanSlug !== currentRouteSlug || !this.routeAutomat) { const view = this.handle.state;
this._unsubRoute?.(); const catId = view?.data?.categoryId ?? resolvedId;
this.routeAutomat = getOrCreateCategoryAutomat({ this.setState({
slugPath: categorySlugPath, resolvedCategoryId: catId,
categoryId: resolvedId, articles: view?.data?.articles ?? [],
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
}); });
this.subscribeViewHandle();
} else if (resolvedId !== resolvedCategoryId) {
this.setState({ this.setState({
resolvedCategoryId: resolvedId, resolvedCategoryId: resolvedId,
articles: this.routeAutomat?.state?.articles ?? [], childCategories: getCategoryChildren(categories, resolvedId),
loaded: this.routeAutomat?.state?.loaded ?? false,
loading: this.routeAutomat?.state?.loading ?? false,
routeError: this.routeAutomat?.state?.error ?? null,
}); });
this.subscribeRouteAutomat();
} }
}; };
@@ -294,14 +303,8 @@ class ArtikelKategorienPage extends PureComponent {
}; };
handleRetryArticles = () => { handleRetryArticles = () => {
if (this.routeAutomat) { if (this.handle) {
if (typeof this.routeAutomat.retry === 'function') { void this.handle.reload().catch(() => {});
void this.routeAutomat.retry();
} else if (typeof this.routeAutomat.actions?.retry === 'function') {
void this.routeAutomat.actions.retry();
} else {
void this.routeAutomat.setDirty({ keep: false, now: true });
}
} }
}; };
@@ -369,7 +372,10 @@ class ArtikelKategorienPage extends PureComponent {
} }
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId); const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, resolvedCategoryId); const childCategories =
this.state.childCategories && this.state.childCategories.length > 0
? this.state.childCategories
: getCategoryChildren(categories, resolvedCategoryId);
const canGoBack = const canGoBack =
resolvedCategoryId != null && resolvedCategoryId != null &&
rootCategoryId != null && rootCategoryId != null &&

View File

@@ -132,3 +132,48 @@ export function getCategoryDescendantIds(categories, categoryId) {
} }
return result; return result;
} }
/**
* Normalizes a category slug path by stripping leading/trailing slashes.
* @param {string|null|undefined} slugPath
* @returns {string}
*/
export function cleanCategorySlugPath(slugPath) {
return (slugPath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Resolves a numeric/string category ID from a URL slug path using the category tree.
* @param {object} categories - id -> node map
* @param {number|string|null} rootCategoryId
* @param {string|null|undefined} slugPath
* @returns {number|string|null}
*/
export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) {
if (!categories || rootCategoryId == null) return null;
let segments = (slugPath ?? '')
.split('/')
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
if (segments[0] === ROOT_CATEGORY_NAME) {
segments = segments.slice(1);
}
if (segments.length === 0) {
return rootCategoryId;
}
let currentId = rootCategoryId;
for (const slug of segments) {
const children = getCategoryChildren(categories, currentId);
const match = children.find(
(child) => categorySlug(child, rootCategoryId) === slug,
);
if (!match) return null;
currentId = match.id;
}
return currentId;
}