This commit is contained in:
seb
2026-09-23 08:16:14 +02:00
parent 49057dc0d6
commit c94ce20b7a
7 changed files with 306 additions and 437 deletions

View File

@@ -17,7 +17,7 @@
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
8. [Critical Agent Rules & Common Pitfalls](#8-critical-agent-rules--common-pitfalls)
9. [Deep Dive: The Automats (`react-automat`)](#9-deep-dive-the-automats-react-automat)
10. [Deep Dive: Redux Toolkit Architecture](#10-deep-dive-redux-toolkit-architecture)
10. [Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)
11. [`dashgui` Component Library Integration](#11-dashgui-component-library-integration)
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
@@ -49,8 +49,7 @@ flow:
component: dashfront
runtime: React 19 (PureComponent & Function Components)
state_layers:
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat]
redux_slices: [company, category, brand, unit, posterminal]
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat]
transport: src/api (http(), ApiClient)
egress:
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
@@ -87,7 +86,7 @@ flow:
| **Routing** | `react-router-dom` v7 | Configured in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) with HMR router preservation |
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#c5ddd8` in `src/colors.js` |
| **Shared GUI** | `dashgui` (`file:../dashgui`) | `PanelTile`, `RecordList`, `Tile`, `SingleLineTextEdit`, `ElevatedPill` |
| **State Management** | `react-automat` + Redux Toolkit | Automats for reactive domain models; Redux for category tree & session bootstrap |
| **State Management** | `react-automat` | Single state layer for all domain & configuration data (articles, categories, company, brands, units, POS terminals, session) |
| **Notifications** | `notistack` | `useSnackbar()` / `<SnackbarProvider>` in [src/main.jsx](file:///home/seb/src/dashfront/src/main.jsx) |
| **Media & Images** | `@jsquash/avif`, `@jsquash/resize`, `thumbhash` | Client-side AVIF encoding and instant thumbnail hash generation |
| **Documents** | `pdfconvert-wasm` | In-browser PDF processing for supplier delivery invoices & receipts |
@@ -104,9 +103,14 @@ src/
│ ├── ApiClient.js # Object-oriented API client (this.article, this.delivery, etc.)
│ ├── StorageManager.js # Client-side cache / local storage management
│ └── storageManagerContext.js # React context provider for StorageManager
├── automats/ # react-automat stores with fine-grained subscriptions
├── automats/ # react-automat state modules (fine-grained subscriptions)
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
│ ├── authAutomat.js # Authentication state, company list, company selection
│ ├── authAutomat.js # Authentication state, session bootstrap, company selection
│ ├── companyAutomat.js # Active company config (tax rates, root category, legal details)
│ ├── categoryAutomat.js # Hierarchical category tree, breadcrumbs, and category articles
│ ├── brandAutomat.js # Catalog brands / manufacturer list
│ ├── unitAutomat.js # Units of measure (Stück, kg, Liter, ...)
│ ├── posterminalAutomat.js # POS terminal config and enabled categories
│ ├── deliveryEditorAutomat.js # Multi-line delivery receipt / invoice editor & draft state
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
@@ -132,14 +136,6 @@ src/
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
│ └── infos/ # System and company information page
├── store/ # Redux Toolkit store (legacy & tree slice)
│ ├── index.js # configureStore with dev logger & listener middleware
│ ├── authSlice.js # Redux auth state (session bootstrap, company selection)
│ ├── companySlice.js # Redux company details (tax rates, rootCategoryId)
│ ├── categorySlice.js # Redux category tree, breadcrumbs, category articles
│ ├── brandSlice.js # Redux brand list and lookup
│ ├── posterminalSlice.js # Redux POS terminals configuration
│ └── unitSlice.js # Redux unit of measure definitions
├── theme/ # MUI theme definitions (light/dark palette, typography, components)
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
├── appRouter.jsx # react-router-dom route definitions
@@ -193,10 +189,10 @@ In `dashfront`, two state management solutions coexist harmoniously:
}
```
### B. Redux Toolkit (`src/store/`)
- Used for static/relational trees: `company`, hierarchical `category` tree, `brand`, `posterminal`, and `unit`.
- Uses `connect(mapStateToProps, mapDispatchToProps)` to connect to `PureComponent` classes or functional hooks where appropriate.
- Coordinated via `createListenerMiddleware`: when `authAutomat` emits an auth change (`auth/sessionEstablished` or `auth/companyChanged`), listeners automatically dispatch loads for company info, brands, POS terminals, and units.
### B. Configuration Automats (`src/automats/`)
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
- Reads use the same fine-grained `subscribe`/selector pattern as the domain automats.
- Cross-automat coordination is done by subscribing to `authAutomat`: when it reaches `authed` with a `selectedCompanyId`, these automats load themselves and reset on `anon` (see [§10](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)).
---
@@ -278,7 +274,7 @@ npm run preview
5. **Binary / Image Handling**:
- Image uploads convert source images into AVIF variants client-side.
- Frontend components use `thumbhash` for instant blur placeholders while loading full images via `/api/image/{fullHash}` or `/api/thumb/{fullHash}`.
6. **No Hooks in PureComponents**: Never call React hooks (`useState`, `useSelector`, `useEffect`) inside `PureComponent` classes. Automats and `connect()` provide direct reactive subscription without hooks.
6. **No Hooks in PureComponents**: Never call React hooks (`useState`, `useEffect`) inside `PureComponent` classes. Automats provide direct reactive subscription without hooks.
7. **Always Unsubscribe**: Every `automat.subscribe(this, ...)` in `componentDidMount()` must have its cleanup callback called in `componentWillUnmount()`.
---
@@ -301,7 +297,7 @@ All domain Automats are located in [src/automats/](file:///home/seb/src/dashfron
### 2. `authAutomat` ([src/automats/authAutomat.js](file:///home/seb/src/dashfront/src/automats/authAutomat.js))
- **Role**: Manages user authentication lifecycle and multi-tenant company selection.
- **State**: `{ user: AuthUser | null, googleClientId: string | null, status: 'loading' | 'anon' | 'authed' }`.
- **Bridge to Redux**: Calls `emit(action)` registered via `setAuthEventDispatcher(store.dispatch)`. Emits:
- **Consumed by other automats**: The company, category, brand, unit, and posterminal automats `subscribe` to `authAutomat` and (re)load or reset on its state changes. `authAutomat` also emits lifecycle events via the registered `setAuthEventDispatcher`:
- `auth/sessionEstablished`
- `auth/companyChanged`
- `auth/loggedOut`
@@ -326,29 +322,33 @@ All domain Automats are located in [src/automats/](file:///home/seb/src/dashfron
---
## 10. Deep Dive: Redux Toolkit Architecture
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
Redux Toolkit manages static, relational, and configuration data in [src/store/](file:///home/seb/src/dashfront/src/store/):
The remaining static/relational/configuration state is **also** managed by `react-automat`. These automats live alongside the domain automats in [src/automats/](file:///home/seb/src/dashfront/src/automats/) and follow the same subscribe/selector pattern:
### Slices
1. **`categorySlice`**: Hierarchical category tree structure, category breadcrumbs, article-category memberships, category reordering (`moveCategory`, `reorderCategories`).
2. **`companySlice`**: Active company configuration, tax rates (standard, reduced), root category ID, company legal details.
3. **`brandSlice`**: Catalog brands and manufacturer list.
4. **`posterminalSlice`**: Registered point-of-sale hardware terminals.
5. **`unitSlice`**: Allowed units of measure (Stück, kg, Liter, etc.).
1. **`companyAutomat`** ([src/automats/companyAutomat.js](file:///home/seb/src/dashfront/src/automats/companyAutomat.js)): Active company configuration, tax rates (standard, reduced), root category ID, company legal details. Selectors: `selectCompanyTaxrate()`, `selectCompanyReducedTaxrate()`, `selectCompanyRootCategoryId()`, `selectCompanyName()`, …
2. **`categoryAutomat`** ([src/automats/categoryAutomat.js](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)): Hierarchical category tree (id → node map), breadcrumbs, category navigation, article lists per category, and category/article mutations (`createCategory`, `createArticle`, `deleteCategory`, `moveCategory`).
3. **`brandAutomat`** ([src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)): Catalog brands / manufacturer list (`load`, `upsert`).
4. **`posterminalAutomat`** ([src/automats/posterminalAutomat.js](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js)): Registered point-of-sale hardware terminals and their enabled categories.
5. **`unitAutomat`** ([src/automats/unitAutomat.js](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)): Allowed units of measure (Stück, kg, Liter, etc.).
### Automatic Bootstrap with Listener Middleware
In [src/store/index.js](file:///home/seb/src/dashfront/src/store/index.js), `createListenerMiddleware` coordinates data loading automatically when auth events fire:
### Automatic Bootstrap via `authAutomat` Subscriptions
Each of these automats subscribes directly to `authAutomat` at module load and coordinates its own loading:
```js
companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
// e.g. src/automats/companyAutomat.js
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
```
When `auth/sessionEstablished` or `auth/companyChanged` occurs, these listeners trigger thunks to reload company tax settings, brands, units, and POS terminals without boilerplate in React views.
When the auth state becomes `authed` with a `selectedCompanyId` (on session bootstrap, login, or company switch), the company, brand, unit, and posterminal automats load automatically; on logout/anon they reset. `categoryAutomat.loadTree()` then reads the company's `rootCategoryId` from `companyAutomat`.
### Redux Dev Logger
In development mode (`import.meta.env.DEV`), `reduxDevLogger` intercepts actions from `auth/`, `company/`, `category/`, `brand/`, `posterminal/`, and `unit/`, logging structured before/after counts to the browser console.
### Automatic Debugging
All automats are observable in the [AutomatInspector](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx), which lists every automat registered in `window.__AUTOMATS__` with live state, subscriber counts, and actions.
---
@@ -433,7 +433,7 @@ The frontend connects to the backend Server-Sent Events stream at `/api/events`:
- **Architecture**: When mutations occur on `dashback` (whether triggered by this client, another terminal, or the POS), SSE events are broadcast with payload information (e.g. `{ type: 'article-update', sku: '1001' }`).
- **Reactive Cache Refresh**:
- `artikelAutomat.actions.reloadArticle(sku)` re-fetches the updated article silently, updating subscribers without flicker.
- Category updates trigger Redux category slice refreshes.
- Category updates trigger `categoryAutomat` tree/article reloads.
- Delivery and order updates trigger automatic list reloads.
---
@@ -527,7 +527,8 @@ async function handleSavePrice(sku, netPrice) {
### Recipe 3: Calculating Gross Prices with Company Tax Rates
```jsx
// Prices in the database and API are NET prices.
// Gross prices shown to end users must apply company tax rates from Redux companySlice:
// Gross prices shown to end users must apply company tax rates from companyAutomat
// (selectCompanyTaxrate() / selectCompanyReducedTaxrate()):
export function calculateGrossPrice(netPrice, taxRatePercent) {
const net = Number(netPrice) || 0;
const rate = Number(taxRatePercent) || 0;

View File

@@ -1,134 +0,0 @@
# Redux in Dashfront
Übersicht, wie Redux Toolkit bisher im Frontend eingesetzt wird.
## Stack
- **@reduxjs/toolkit** — Store, Slices, Async Thunks, Listener Middleware
- **react-redux** — `Provider` in `src/main.jsx`, Anbindung der UI über `connect()` (kein `useSelector` / `useDispatch`)
Einstieg:
```
src/main.jsx → <Provider store={store}>
src/store/index.js → configureStore + Middleware
src/store/*Slice.js → State, Thunks, Selectors
```
## Store-Form
Drei Slices unter `src/store/`:
| Key | Datei | Verantwortung |
|------------|---------------------|----------------------------------------------------|
| `auth` | `authSlice.js` | Session, Login/Logout, Firmenwahl / -anlage |
| `company` | `companySlice.js` | Aktuelles Unternehmen laden / aktualisieren |
| `category` | `categorySlice.js` | Kategoriebaum, Navigation, Artikel je Kategorie |
```js
// src/store/index.js
configureStore({
reducer: { auth, company, category },
middleware: defaults + listenerMiddleware + (DEV: reduxDevLogger),
});
```
## Typische Muster
### Async Thunks als API-Grenze
Seiten- und Dialog-Logik dispatcht Thunks; die Thunks rufen `src/api` auf und schreiben Ergebnisse in den Slice. Komponenten sprechen die API nicht direkt für diese Domänen an.
Beispiele:
- `auth/bootstrap`, `auth/login`, `auth/selectCompany`, `auth/createCompany`, `auth/logout`
- `company/loadCurrent`, `company/update`
- `category/loadTree`, `category/loadArticles`, `category/create`, `category/createArticle`, `category/delete`
Fehler in Category-Thunks laufen über `catchBackendError` / `rejectWithValue` und landen als `BackendError` in `state.category.error`. Tree- und Artikel-Responses werden mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validiert.
### Selectors
Jeder Slice exportiert `select…`-Funktionen. UI-Komponenten mappen sie in `mapStateToProps`:
```js
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
needsCompanySetup: selectNeedsCompanySetup(state),
});
export default connect(mapStateToProps)(App);
```
Abgeleitete Category-Selectors (Breadcrumbs, Kinder, „kann zurück“) lesen die Map `categories` und `currentCategoryId` — keine zweite Kopie des Baums in der UI.
### `connect()` statt Hooks
Die Redux-gebundenen Komponenten sind Class- oder Function-Komponenten mit:
```js
connect(mapStateToProps, mapDispatchToProps)(Component)
```
Dispatch-Props wrappen Thunks, oft mit `.unwrap()` in der Komponente für Erfolg/Fehler (Snackbar).
### Listener: Auth → Company
Nach erfolgreichem Bootstrap, Login, Firmenwahl oder Firmenanlage dispatcht ein Listener automatisch `loadCurrentCompany`, wenn `auth.user.selectedCompanyId` gesetzt ist (`companyLoadListener` in `companySlice.js`).
### Cross-Slice-Reset
- `logout.fulfilled` leert `company` und setzt `category` auf `initialState`
- `loadCurrentCompany.fulfilled` mit neuer `companyId` setzt den Category-State zurück (Firmenwechsel)
## Datenfluss (Kurz)
```
App-Start
└─ AuthBootstrap → bootstrapSession
└─ (listener) loadCurrentCompany wenn selectedCompanyId
└─ Category-UI → loadCategoryTree (braucht company.rootCategoryId)
Login / Firmenwahl / -anlage
└─ auth-Thunks → user.selectedCompanyId
└─ (listener) loadCurrentCompany → ggf. Category-Reset
Kategorien-Seite
└─ navigateToCategory / navigateBack (sync)
└─ loadCategoryArticles für Unterkategorien
└─ createCategory / createArticle / deleteCategory (optimistisch im Map-/Listen-State)
```
Status-Felder:
| Slice | Feld(er) | Werte (typisch) |
|----------|----------------------------------|------------------------|
| auth | `status` | `loading` / `anon` / `authed` |
| company | `status` | `idle` / `loading` / `succeeded` / `failed` |
| category | `treeStatus`, `articlesStatus` | `idle` / `loading` / `succeeded` / `failed` |
## Auth-Slice
- **State:** `user`, `googleClientId`, `status`
- **Setup-Gate:** `selectNeedsCompanySetup` → eingeloggt, aber keine `selectedCompanyId` → `CompanySetupDialog`
- `mergeUser` behält beim Partial-Update ggf. die bisherige `selectedCompanyId`
## Company-Slice
- **State:** `current` (normalisiert über `normalizeCompany`), `status`, `error`
- Thunks lesen `auth.user.selectedCompanyId` aus dem Store (`getState`)
- Selectors liefern u. a. Name, `iso639`, Steuersätze, `rootCategoryId` für Category/UI
## Category-Slice
- **Baum:** `categories` als Id→Node-Map, plus `rootCategoryId`, `rootArticles`, `languageId`
- **Navigation:** `currentCategoryId`; Root zeigt `rootArticles`, Unterkategorien laden per `loadCategoryArticles`
- **Sync-Actions:** `navigateToCategory`, `navigateBack`, `resetCategoryState`
- Hilfsfunktionen (`normalizeCategoryNode`, `parseCategoryTreeResponse`, Breadcrumbs, Slugs) leben im Slice und werden u. a. von `categoryRoute.js` für URL-Pfade wiederverwendet
## Dev-Logging
In Development loggt `reduxDevLogger` Actions unter `auth/`, `company/`, `category/` inkl. Payload und kurzem Auth-/Company-Snapshot nach dem Reduce.
## Was (noch) nicht in Redux liegt
Artikel-Detail, Fotos und ähnliche lokale UI-Zustände werden in Komponenten / lokalen Props gehalten, nicht als eigener Slice. Neue Domänen folgen dem bestehenden Muster: API in `src/api`, Thunk + Validierung im Slice, Selectors + `connect` in der UI.

View File

@@ -1,13 +1,12 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
import { ApiClient } from './ApiClient';
import artikelAutomat from '../automats/artikelAutomat';
import companyAutomat from '../automats/companyAutomat';
import brandAutomat from '../automats/brandAutomat';
import unitAutomat from '../automats/unitAutomat';
import authAutomat from '../automats/authAutomat';
import categoryAutomat from '../automats/categoryAutomat';
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
import posterminalAutomat from '../automats/posterminalAutomat';
@@ -175,24 +174,10 @@ export class StorageManager extends ApiClient {
}
/**
* Parsed category map + root id from settled caches, or null if any piece missing.
* @returns {{ categories: object, rootCategoryId: unknown } | null}
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const company = this.company;
if (company == null) return null;
const treeData = this.categoryTree;
if (treeData != null) {
// Legacy: tree was loaded via the old cache mechanism
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
return {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
};
}
// New path: use categoryAutomat state directly
const catState = categoryAutomat.state;
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
return {
@@ -413,12 +398,6 @@ export class StorageManager extends ApiClient {
);
const articleIndexCache = this.#articleIndexCache;
// category tree: deferred, reloaded via categoryAutomat
const categoryTreeCache = this.#createDeferredCache(
'GET /category/tree',
() => this.#loadCategoryTree(),
);
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
@@ -499,11 +478,22 @@ export class StorageManager extends ApiClient {
// Return a single-element array so .find() still works.
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
// ── Category tree bridge ─────────────────────────────────────────────────
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
/** Category tree promise resolving when categoryAutomat tree is loaded. */
categoryTreePromise: {
get: () => categoryAutomat.state.treeStatus === 'succeeded'
? Promise.resolve(categoryAutomat.state.categories)
: new Promise((resolve, reject) => {
const unsub = categoryAutomat.subscribe((s) => {
if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); }
else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload category tree via categoryAutomat. */
updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() },
/** Current categories map from categoryAutomat, or null. */
categoryTree: { get: () => categoryAutomat.state.categories },
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise },
@@ -556,7 +546,6 @@ export class StorageManager extends ApiClient {
// Unauthenticated or logged out
this.#disconnectEvents();
articleIndexCache.clear();
categoryTreeCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
@@ -568,7 +557,6 @@ export class StorageManager extends ApiClient {
// Company established or switched
if (prevCompanyId) {
articleIndexCache.clear();
categoryTreeCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
@@ -678,11 +666,13 @@ export class StorageManager extends ApiClient {
this.#disconnectEvents();
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
es.addEventListener('category', () => {
void this.updateCategoryTree().catch((err) => {
console.error('[storageMgr] category SSE refresh failed', err);
void categoryAutomat.actions.loadTree().catch((err) => {
console.error('[storageMgr] category tree SSE refresh failed', err);
});
invalidateAllCategoryViews();
});
es.addEventListener('articles', () => {
invalidateAllCategoryViews();
void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles SSE refresh failed', err);
});
@@ -753,15 +743,6 @@ export class StorageManager extends ApiClient {
}));
}
async #loadCategoryTree() {
const data = await this.category.tree();
requireObject(data, 'GET /api/category/tree');
requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
requireDefined(data.languageId, 'category/tree.languageId');
return data;
}
async #loadDeliveries() {
const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery');

View File

@@ -16,13 +16,8 @@ import {
* @property {string|null} languageId
* @property {Record<string,object>|null} categories id → node map
* @property {number|string|null} rootCategoryId
* @property {Array<object>} rootArticles
* @property {number|string|null} currentCategoryId
* @property {Array<object>} articles
* @property {number} articlesTotal
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
* @property {string|null} error
*/
@@ -32,25 +27,117 @@ const initialState = {
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
let categoryAutomat;
// ─── Pure helpers ──────────────────────────────────────────────────────────────
// Registry of active per-route category view automats
const categoryViewInstances = new Set();
// Reverse index for O(1) parentId lookup to target route automat
const categoryIdToRouteName = new Map();
function articlesForCategory(state, categoryId) {
if (String(categoryId) === String(state.rootCategoryId)) {
return state.rootArticles;
/**
* Loader for category view automat: fetches articles for categoryId.
* Promise only resolves when all articles are loaded.
*/
async function fetchCategoryArticles(automat) {
try {
const res = await categoryApi.getArticles(automat.state.categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
return { articles, loaded: true, loading: false, error: null };
} catch (err) {
const errorMsg = err?.message ?? String(err);
automat.setState({ loading: false, loaded: false, error: errorMsg });
throw err;
}
return [];
}
/**
* Get or create a per-route category view automat for the given slug and categoryId.
* Returns null if categoryId is null.
*
* @param {{ slugPath?: string, categoryId: number|string|null }} param0
* @returns {Automat|null}
*/
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
if (categoryId == null) return null;
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.
*/
export function invalidateAllCategoryViews() {
for (const automat of categoryViewInstances) {
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).
*/
export function disposeAllCategoryViewAutomats() {
for (const automat of categoryViewInstances) {
try {
automat.dispose();
} catch (e) {
console.error('[categoryAutomat] dispose view failed', e);
}
}
categoryViewInstances.clear();
categoryIdToRouteName.clear();
}
// ─── Actions ───────────────────────────────────────────────────────────────────
@@ -73,11 +160,6 @@ const actions = {
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
currentCategoryId: parsed.rootCategoryId,
rootArticles: parsed.rootArticles,
articles: parsed.rootArticles,
articlesTotal: parsed.rootArticles.length,
articlesStatus: 'succeeded',
error: null,
});
} catch (err) {
@@ -89,62 +171,6 @@ const actions = {
}
},
/**
* Load articles for a non-root category.
* @param {number|string} categoryId
*/
async loadArticles(categoryId) {
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
try {
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
categoryAutomat.setState({
articlesStatus: 'succeeded',
articles,
articlesTotal: articles.length,
});
} catch (err) {
categoryAutomat.setState({
articlesStatus: 'failed',
articles: [],
articlesTotal: 0,
error: err?.message ?? String(err),
});
throw err;
}
},
/** Navigate to a category by id. */
navigateTo(categoryId) {
const { rootCategoryId, rootArticles } = categoryAutomat.state;
const isRoot = String(categoryId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: categoryId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
},
/** Navigate up to the parent category. */
navigateBack() {
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const current = categories?.[String(currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
const isRoot = String(parentId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: parentId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
}
},
/** Toggle rename/edit mode for the category grid. */
setRenameMode(enabled) {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
@@ -192,17 +218,17 @@ const actions = {
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
if (String(created.parentId) === String(currentCategoryId)) {
patch.articles = [...articles, created];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
if (created && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId));
if (routeName) {
const routeAutomat = Automat.get(routeName);
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
routeAutomat.setState({
articles: [...routeAutomat.state.articles, created],
});
}
}
}
if (String(created.parentId) === String(rootCategoryId)) {
patch.rootArticles = [...rootArticles, created];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
@@ -218,19 +244,17 @@ const actions = {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
if (forCurrent.length > 0) {
patch.articles = [...articles, ...forCurrent];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
if (created.length > 0 && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId));
if (routeName) {
const routeAutomat = Automat.get(routeName);
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
routeAutomat.setState({
articles: [...routeAutomat.state.articles, ...created],
});
}
}
}
const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId));
if (forRoot.length > 0) {
patch.rootArticles = [...rootArticles, ...forRoot];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
@@ -247,14 +271,9 @@ const actions = {
const res = await categoryApi.updateName(categoryId, trimmed);
const updated = normalizeCategoryNode(res.category);
const key = String(updated.id);
const { categories, rootArticles } = categoryAutomat.state;
const { categories } = categoryAutomat.state;
categoryAutomat.setState({
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
rootArticles: rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name: updated.name }
: row,
),
});
return updated;
},
@@ -264,7 +283,7 @@ const actions = {
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
*/
async reorderCategories({ parentId, categoryIds }) {
const targetParentId = parentId != null ? parentId : categoryAutomat.state.currentCategoryId;
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
const res = await categoryApi.reorder(targetParentId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(targetParentId);
@@ -304,18 +323,11 @@ const actions = {
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const { categories } = categoryAutomat.state;
if (!categories) return;
const next = { ...categories };
delete next[String(categoryId)];
const patch = { categories: next };
if (String(currentCategoryId) === String(categoryId)) {
patch.currentCategoryId = rootCategoryId;
patch.articles = rootArticles;
patch.articlesTotal = rootArticles.length;
patch.articlesStatus = 'succeeded';
}
categoryAutomat.setState(patch);
categoryAutomat.setState({ categories: next });
return categoryId;
},
@@ -372,55 +384,17 @@ const actions = {
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from both article lists (used after article deletion). */
/** Remove an article from all live category view lists (used after article deletion). */
removeArticleBySku(sku) {
categoryAutomat.setState({
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
});
},
/**
* Patch article fields and category membership in local lists.
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
*/
patchArticleInLists({ sku, categoryIds, ...fields }) {
if (sku == null) return;
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
const patchList = (list) =>
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
let nextArticles = patchList(articles);
let nextRoot = patchList(rootArticles);
let nextArticlesTotal = nextArticles.length;
if (categoryIds !== undefined) {
const newIds = categoryIds ?? [];
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
if (rootCategoryId != null) {
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
const wasInRoot = nextRoot.some((r) => r.sku === sku);
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
}
if (currentCategoryId != null) {
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
nextArticlesTotal = nextArticles.length;
if (!sku) return;
for (const automat of categoryViewInstances) {
const articles = automat.state?.articles;
if (Array.isArray(articles) && articles.some((r) => r.sku === sku)) {
automat.setState({
articles: articles.filter((r) => r.sku !== sku),
});
}
}
categoryAutomat.setState({
articles: nextArticles,
articlesTotal: nextArticlesTotal,
rootArticles: nextRoot,
});
},
/**
@@ -445,6 +419,7 @@ const actions = {
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
categoryAutomat.setState(initialState);
},
};
@@ -464,6 +439,7 @@ companyAutomat.subscribe((company) => {
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
disposeAllCategoryViewAutomats();
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {

View File

@@ -51,4 +51,8 @@ export {
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
getOrCreateCategoryAutomat,
disposeAllCategoryViewAutomats,
invalidateAllCategoryViews,
} from './categoryAutomat';

View File

@@ -24,7 +24,7 @@ import {
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import categoryAutomat from '../../automats/categoryAutomat';
import categoryAutomat, { getOrCreateCategoryAutomat } from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
@@ -37,29 +37,13 @@ import {
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s;
const breadcrumbs = getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, currentCategoryId);
const canGoBack =
currentCategoryId != null &&
rootCategoryId != null &&
String(currentCategoryId) !== String(rootCategoryId) &&
categories?.[String(currentCategoryId)]?.parentId != null &&
categories[String(currentCategoryId)].parentId !== 0;
const { categories, rootCategoryId, renameMode, treeStatus, error } = s;
return {
categories,
rootCategoryId,
treeStatus,
articlesStatus,
error,
currentCategoryId,
breadcrumbs,
childCategories,
articles,
articlesTotal,
canGoBack,
renameMode,
treeStatus,
error,
};
}
@@ -76,9 +60,30 @@ const gridSx = {
class ArtikelKategorienPage extends PureComponent {
constructor(props) {
super(props);
const catState = selectFromCategory(categoryAutomat.state);
const { categories, rootCategoryId, treeStatus } = catState;
let resolvedCategoryId = null;
if (treeStatus === 'succeeded' && categories && rootCategoryId != null) {
resolvedCategoryId = resolveCategoryIdFromSlugPath(
categories,
rootCategoryId,
props.categorySlugPath,
);
}
this.routeAutomat = resolvedCategoryId != null
? getOrCreateCategoryAutomat({ slugPath: props.categorySlugPath, categoryId: resolvedCategoryId })
: null;
this.state = {
drafts: {},
...selectFromCategory(categoryAutomat.state),
...catState,
resolvedCategoryId,
articles: this.routeAutomat?.state?.articles ?? [],
loaded: this.routeAutomat?.state?.loaded ?? false,
loading: this.routeAutomat?.state?.loading ?? false,
routeError: this.routeAutomat?.state?.error ?? null,
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null,
@@ -99,31 +104,28 @@ class ArtikelKategorienPage extends PureComponent {
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
this.subscribeRouteAutomat();
this.syncSortable();
}
componentDidUpdate(prevState) {
// Note: PureComponent — comparisons use this.state not this.props
componentDidUpdate(prevProps, prevState) {
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded();
}
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
}
if (
this.state.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
this.props.categorySlugPath !== prevProps.categorySlugPath ||
this.state.categories !== prevState.categories ||
this.state.rootCategoryId !== prevState.rootCategoryId
) {
this.syncCategoryFromUrl();
}
this._prevSlugPath = this.props.categorySlugPath;
if (this.state.currentCategoryId !== prevState.currentCategoryId) {
this.ensureArticlesLoaded();
}
if (this.state.renameMode !== prevState.renameMode) {
if (
this.state.renameMode !== prevState.renameMode ||
(this.state.renameMode && this.state.resolvedCategoryId !== prevState.resolvedCategoryId)
) {
this.syncSortable();
}
}
@@ -132,16 +134,33 @@ class ArtikelKategorienPage extends PureComponent {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this._unsubRoute?.();
this.sortableModule?.destroy();
}
subscribeRouteAutomat() {
this._unsubRoute?.();
this._unsubRoute = null;
if (this.routeAutomat) {
this._unsubRoute = this.routeAutomat.subscribe(this, (s) => ({
articles: s.articles,
loaded: s.loaded,
loading: s.loading,
routeError: s.error,
}));
}
}
#ensureModules = () => {
const el = this.gridRef.current;
if (!el) return;
if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, {
onReorder: ({ categoryIds }) =>
categoryAutomat.actions.reorderCategories({ categoryIds }),
categoryAutomat.actions.reorderCategories({
parentId: this.state.resolvedCategoryId,
categoryIds,
}),
});
}
};
@@ -168,19 +187,12 @@ class ArtikelKategorienPage extends PureComponent {
void posterminalAutomat.actions.load();
};
ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state;
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
if (String(currentCategoryId) === String(rootCategoryId)) return;
void categoryAutomat.actions.loadArticles(currentCategoryId);
};
syncCategoryFromUrl = () => {
const {
categories,
rootCategoryId,
currentCategoryId,
treeStatus,
resolvedCategoryId,
} = this.state;
const { categorySlugPath, navigate } = this.props;
@@ -204,8 +216,23 @@ class ArtikelKategorienPage extends PureComponent {
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
}
if (resolvedId !== currentCategoryId) {
categoryAutomat.actions.navigateTo(resolvedId);
const cleanSlug = (categorySlugPath ?? '').replace(/^\/+|\/+$/g, '');
const currentRouteSlug = this.routeAutomat?.state?.slug;
if (resolvedId !== resolvedCategoryId || cleanSlug !== currentRouteSlug || !this.routeAutomat) {
this._unsubRoute?.();
this.routeAutomat = getOrCreateCategoryAutomat({
slugPath: categorySlugPath,
categoryId: resolvedId,
});
this.setState({
resolvedCategoryId: resolvedId,
articles: this.routeAutomat?.state?.articles ?? [],
loaded: this.routeAutomat?.state?.loaded ?? false,
loading: this.routeAutomat?.state?.loading ?? false,
routeError: this.routeAutomat?.state?.error ?? null,
});
this.subscribeRouteAutomat();
}
};
@@ -266,14 +293,20 @@ class ArtikelKategorienPage extends PureComponent {
};
handleRetryArticles = () => {
const { currentCategoryId } = this.state;
if (currentCategoryId) {
void categoryAutomat.actions.loadArticles(currentCategoryId);
if (this.routeAutomat) {
if (typeof this.routeAutomat.retry === 'function') {
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 });
}
}
};
handleNavigateBack = () => {
const { breadcrumbs } = this.state;
const { categories, resolvedCategoryId, rootCategoryId } = this.state;
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
};
@@ -289,13 +322,12 @@ class ArtikelKategorienPage extends PureComponent {
categories,
rootCategoryId,
treeStatus,
articlesStatus,
error,
canGoBack,
breadcrumbs,
currentCategoryId,
childCategories,
resolvedCategoryId,
articles,
loaded,
loading,
routeError,
renameMode,
terminals,
drafts,
@@ -335,7 +367,16 @@ class ArtikelKategorienPage extends PureComponent {
);
}
const showArticlesLoading = articlesStatus === 'loading';
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, resolvedCategoryId);
const canGoBack =
resolvedCategoryId != null &&
rootCategoryId != null &&
String(resolvedCategoryId) !== String(rootCategoryId) &&
categories?.[String(resolvedCategoryId)]?.parentId != null &&
categories[String(resolvedCategoryId)].parentId !== 0;
const showArticlesLoading = loading;
return (
<>
@@ -344,9 +385,9 @@ class ArtikelKategorienPage extends PureComponent {
onNavigate={this.navigateToCategoryById}
/>
{error && articlesStatus === 'failed' && (
{routeError && (
<BackendError
error={error}
error={routeError}
sx={{ mb: 2 }}
onRetry={this.handleRetryArticles}
/>
@@ -356,12 +397,12 @@ class ArtikelKategorienPage extends PureComponent {
{canGoBack && (
<BackTile
onClick={this.handleNavigateBack}
data-id={String(breadcrumbs[breadcrumbs.length - 2].id)}
data-id={breadcrumbs.length >= 2 ? String(breadcrumbs[breadcrumbs.length - 2].id) : undefined}
className="sortable-item not-sortable"
/>
)}
{childCategories.map((item, index) => {
{childCategories.map((item) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
const draftName =
drafts[item.id] !== undefined
@@ -469,10 +510,10 @@ class ArtikelKategorienPage extends PureComponent {
/>
))}
{renameMode && <NewCategoryTile parentId={currentCategoryId} />}
{renameMode && articlesStatus === 'succeeded' && (
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
{renameMode && loaded && (
<NewArticleTile
parentId={currentCategoryId}
parentId={resolvedCategoryId}
onOpenArticle={this.handleArticleClick}
sx={{ gridColumnStart: 1 }}
/>
@@ -505,3 +546,4 @@ class ArtikelKategorienPage extends PureComponent {
}
export default withCategoryRoute(ArtikelKategorienPage);

View File

@@ -35,7 +35,6 @@ export function parseCategoryTreeResponse(data, companyRootCategoryId) {
return {
rootCategoryId,
categories,
rootArticles: Array.isArray(data.articles) ? data.articles : [],
languageId: data.languageId,
};
}