diff --git a/AGENTS.md b/AGENTS.md index def4dfa..0f178a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()` / `` 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; diff --git a/README.redux.md b/README.redux.md deleted file mode 100644 index e8aa275..0000000 --- a/README.redux.md +++ /dev/null @@ -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 → -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. diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 6454e40..15bb62c 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -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, 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'); diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 58b0e99..3cf4f20 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -16,13 +16,8 @@ import { * @property {string|null} languageId * @property {Record|null} categories id → node map * @property {number|string|null} rootCategoryId - * @property {Array} rootArticles - * @property {number|string|null} currentCategoryId - * @property {Array} 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) { diff --git a/src/automats/index.js b/src/automats/index.js index e90d14d..3ab56e1 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -51,4 +51,8 @@ export { categoryDisplayName, categorySlug, normalizeCategoryMap, + getOrCreateCategoryAutomat, + disposeAllCategoryViewAutomats, + invalidateAllCategoryViews, } from './categoryAutomat'; + diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 05832bd..fc9dd8f 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -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 && ( @@ -356,12 +397,12 @@ class ArtikelKategorienPage extends PureComponent { {canGoBack && ( = 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 && } - {renameMode && articlesStatus === 'succeeded' && ( + {renameMode && } + {renameMode && loaded && ( @@ -505,3 +546,4 @@ class ArtikelKategorienPage extends PureComponent { } export default withCategoryRoute(ArtikelKategorienPage); + diff --git a/src/utils/categoryUtils.js b/src/utils/categoryUtils.js index 3d9a8cf..5c40d5e 100644 --- a/src/utils/categoryUtils.js +++ b/src/utils/categoryUtils.js @@ -35,7 +35,6 @@ export function parseCategoryTreeResponse(data, companyRootCategoryId) { return { rootCategoryId, categories, - rootArticles: Array.isArray(data.articles) ? data.articles : [], languageId: data.languageId, }; }