Compare commits
2 Commits
3ae840b2fe
...
c94ce20b7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c94ce20b7a | ||
|
|
49057dc0d6 |
79
AGENTS.md
79
AGENTS.md
@@ -17,7 +17,7 @@
|
|||||||
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
|
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
|
||||||
8. [Critical Agent Rules & Common Pitfalls](#8-critical-agent-rules--common-pitfalls)
|
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)
|
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)
|
11. [`dashgui` Component Library Integration](#11-dashgui-component-library-integration)
|
||||||
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
|
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
|
||||||
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
|
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
|
||||||
@@ -49,8 +49,7 @@ flow:
|
|||||||
component: dashfront
|
component: dashfront
|
||||||
runtime: React 19 (PureComponent & Function Components)
|
runtime: React 19 (PureComponent & Function Components)
|
||||||
state_layers:
|
state_layers:
|
||||||
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat]
|
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat]
|
||||||
redux_slices: [company, category, brand, unit, posterminal]
|
|
||||||
transport: src/api (http(), ApiClient)
|
transport: src/api (http(), ApiClient)
|
||||||
egress:
|
egress:
|
||||||
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
|
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 |
|
| **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` |
|
| **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` |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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.)
|
│ ├── ApiClient.js # Object-oriented API client (this.article, this.delivery, etc.)
|
||||||
│ ├── StorageManager.js # Client-side cache / local storage management
|
│ ├── StorageManager.js # Client-side cache / local storage management
|
||||||
│ └── storageManagerContext.js # React context provider for StorageManager
|
│ └── 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
|
│ ├── 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
|
│ ├── deliveryEditorAutomat.js # Multi-line delivery receipt / invoice editor & draft state
|
||||||
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
|
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
|
||||||
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
|
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
|
||||||
@@ -132,14 +136,6 @@ src/
|
|||||||
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
|
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
|
||||||
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
|
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
|
||||||
│ └── infos/ # System and company information 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)
|
├── theme/ # MUI theme definitions (light/dark palette, typography, components)
|
||||||
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
|
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
|
||||||
├── appRouter.jsx # react-router-dom route definitions
|
├── appRouter.jsx # react-router-dom route definitions
|
||||||
@@ -193,10 +189,10 @@ In `dashfront`, two state management solutions coexist harmoniously:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### B. Redux Toolkit (`src/store/`)
|
### B. Configuration Automats (`src/automats/`)
|
||||||
- Used for static/relational trees: `company`, hierarchical `category` tree, `brand`, `posterminal`, and `unit`.
|
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
|
||||||
- Uses `connect(mapStateToProps, mapDispatchToProps)` to connect to `PureComponent` classes or functional hooks where appropriate.
|
- Reads use the same fine-grained `subscribe`/selector pattern as the domain automats.
|
||||||
- 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.
|
- 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**:
|
5. **Binary / Image Handling**:
|
||||||
- Image uploads convert source images into AVIF variants client-side.
|
- 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}`.
|
- 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()`.
|
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))
|
### 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.
|
- **Role**: Manages user authentication lifecycle and multi-tenant company selection.
|
||||||
- **State**: `{ user: AuthUser | null, googleClientId: string | null, status: 'loading' | 'anon' | 'authed' }`.
|
- **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/sessionEstablished`
|
||||||
- `auth/companyChanged`
|
- `auth/companyChanged`
|
||||||
- `auth/loggedOut`
|
- `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. **`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()`, …
|
||||||
1. **`categorySlice`**: Hierarchical category tree structure, category breadcrumbs, article-category memberships, category reordering (`moveCategory`, `reorderCategories`).
|
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`).
|
||||||
2. **`companySlice`**: Active company configuration, tax rates (standard, reduced), root category ID, company legal details.
|
3. **`brandAutomat`** ([src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)): Catalog brands / manufacturer list (`load`, `upsert`).
|
||||||
3. **`brandSlice`**: Catalog brands and manufacturer list.
|
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.
|
||||||
4. **`posterminalSlice`**: Registered point-of-sale hardware terminals.
|
5. **`unitAutomat`** ([src/automats/unitAutomat.js](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)): Allowed units of measure (Stück, kg, Liter, etc.).
|
||||||
5. **`unitSlice`**: Allowed units of measure (Stück, kg, Liter, etc.).
|
|
||||||
|
|
||||||
### Automatic Bootstrap with Listener Middleware
|
### Automatic Bootstrap via `authAutomat` Subscriptions
|
||||||
In [src/store/index.js](file:///home/seb/src/dashfront/src/store/index.js), `createListenerMiddleware` coordinates data loading automatically when auth events fire:
|
Each of these automats subscribes directly to `authAutomat` at module load and coordinates its own loading:
|
||||||
```js
|
```js
|
||||||
companyLoadListener(listenerMiddleware);
|
// e.g. src/automats/companyAutomat.js
|
||||||
unitLoadListener(listenerMiddleware);
|
authAutomat.subscribe((auth) => {
|
||||||
brandLoadListener(listenerMiddleware);
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
posterminalLoadListener(listenerMiddleware);
|
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
|
### Automatic Debugging
|
||||||
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.
|
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' }`).
|
- **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**:
|
- **Reactive Cache Refresh**:
|
||||||
- `artikelAutomat.actions.reloadArticle(sku)` re-fetches the updated article silently, updating subscribers without flicker.
|
- `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.
|
- 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
|
### Recipe 3: Calculating Gross Prices with Company Tax Rates
|
||||||
```jsx
|
```jsx
|
||||||
// Prices in the database and API are NET prices.
|
// 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) {
|
export function calculateGrossPrice(netPrice, taxRatePercent) {
|
||||||
const net = Number(netPrice) || 0;
|
const net = Number(netPrice) || 0;
|
||||||
const rate = Number(taxRatePercent) || 0;
|
const rate = Number(taxRatePercent) || 0;
|
||||||
|
|||||||
134
README.redux.md
134
README.redux.md
@@ -1,134 +0,0 @@
|
|||||||
# Redux in Dashfront
|
|
||||||
|
|
||||||
Übersicht, wie Redux Toolkit bisher im Frontend eingesetzt wird.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **@reduxjs/toolkit** — Store, Slices, Async Thunks, Listener Middleware
|
|
||||||
- **react-redux** — `Provider` in `src/main.jsx`, Anbindung der UI über `connect()` (kein `useSelector` / `useDispatch`)
|
|
||||||
|
|
||||||
Einstieg:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/main.jsx → <Provider store={store}>
|
|
||||||
src/store/index.js → configureStore + Middleware
|
|
||||||
src/store/*Slice.js → State, Thunks, Selectors
|
|
||||||
```
|
|
||||||
|
|
||||||
## Store-Form
|
|
||||||
|
|
||||||
Drei Slices unter `src/store/`:
|
|
||||||
|
|
||||||
| Key | Datei | Verantwortung |
|
|
||||||
|------------|---------------------|----------------------------------------------------|
|
|
||||||
| `auth` | `authSlice.js` | Session, Login/Logout, Firmenwahl / -anlage |
|
|
||||||
| `company` | `companySlice.js` | Aktuelles Unternehmen laden / aktualisieren |
|
|
||||||
| `category` | `categorySlice.js` | Kategoriebaum, Navigation, Artikel je Kategorie |
|
|
||||||
|
|
||||||
```js
|
|
||||||
// src/store/index.js
|
|
||||||
configureStore({
|
|
||||||
reducer: { auth, company, category },
|
|
||||||
middleware: defaults + listenerMiddleware + (DEV: reduxDevLogger),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Typische Muster
|
|
||||||
|
|
||||||
### Async Thunks als API-Grenze
|
|
||||||
|
|
||||||
Seiten- und Dialog-Logik dispatcht Thunks; die Thunks rufen `src/api` auf und schreiben Ergebnisse in den Slice. Komponenten sprechen die API nicht direkt für diese Domänen an.
|
|
||||||
|
|
||||||
Beispiele:
|
|
||||||
|
|
||||||
- `auth/bootstrap`, `auth/login`, `auth/selectCompany`, `auth/createCompany`, `auth/logout`
|
|
||||||
- `company/loadCurrent`, `company/update`
|
|
||||||
- `category/loadTree`, `category/loadArticles`, `category/create`, `category/createArticle`, `category/delete`
|
|
||||||
|
|
||||||
Fehler in Category-Thunks laufen über `catchBackendError` / `rejectWithValue` und landen als `BackendError` in `state.category.error`. Tree- und Artikel-Responses werden mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validiert.
|
|
||||||
|
|
||||||
### Selectors
|
|
||||||
|
|
||||||
Jeder Slice exportiert `select…`-Funktionen. UI-Komponenten mappen sie in `mapStateToProps`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const mapStateToProps = (state) => ({
|
|
||||||
status: selectAuthStatus(state),
|
|
||||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
|
||||||
});
|
|
||||||
export default connect(mapStateToProps)(App);
|
|
||||||
```
|
|
||||||
|
|
||||||
Abgeleitete Category-Selectors (Breadcrumbs, Kinder, „kann zurück“) lesen die Map `categories` und `currentCategoryId` — keine zweite Kopie des Baums in der UI.
|
|
||||||
|
|
||||||
### `connect()` statt Hooks
|
|
||||||
|
|
||||||
Die Redux-gebundenen Komponenten sind Class- oder Function-Komponenten mit:
|
|
||||||
|
|
||||||
```js
|
|
||||||
connect(mapStateToProps, mapDispatchToProps)(Component)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dispatch-Props wrappen Thunks, oft mit `.unwrap()` in der Komponente für Erfolg/Fehler (Snackbar).
|
|
||||||
|
|
||||||
### Listener: Auth → Company
|
|
||||||
|
|
||||||
Nach erfolgreichem Bootstrap, Login, Firmenwahl oder Firmenanlage dispatcht ein Listener automatisch `loadCurrentCompany`, wenn `auth.user.selectedCompanyId` gesetzt ist (`companyLoadListener` in `companySlice.js`).
|
|
||||||
|
|
||||||
### Cross-Slice-Reset
|
|
||||||
|
|
||||||
- `logout.fulfilled` leert `company` und setzt `category` auf `initialState`
|
|
||||||
- `loadCurrentCompany.fulfilled` mit neuer `companyId` setzt den Category-State zurück (Firmenwechsel)
|
|
||||||
|
|
||||||
## Datenfluss (Kurz)
|
|
||||||
|
|
||||||
```
|
|
||||||
App-Start
|
|
||||||
└─ AuthBootstrap → bootstrapSession
|
|
||||||
└─ (listener) loadCurrentCompany wenn selectedCompanyId
|
|
||||||
└─ Category-UI → loadCategoryTree (braucht company.rootCategoryId)
|
|
||||||
|
|
||||||
Login / Firmenwahl / -anlage
|
|
||||||
└─ auth-Thunks → user.selectedCompanyId
|
|
||||||
└─ (listener) loadCurrentCompany → ggf. Category-Reset
|
|
||||||
|
|
||||||
Kategorien-Seite
|
|
||||||
└─ navigateToCategory / navigateBack (sync)
|
|
||||||
└─ loadCategoryArticles für Unterkategorien
|
|
||||||
└─ createCategory / createArticle / deleteCategory (optimistisch im Map-/Listen-State)
|
|
||||||
```
|
|
||||||
|
|
||||||
Status-Felder:
|
|
||||||
|
|
||||||
| Slice | Feld(er) | Werte (typisch) |
|
|
||||||
|----------|----------------------------------|------------------------|
|
|
||||||
| auth | `status` | `loading` / `anon` / `authed` |
|
|
||||||
| company | `status` | `idle` / `loading` / `succeeded` / `failed` |
|
|
||||||
| category | `treeStatus`, `articlesStatus` | `idle` / `loading` / `succeeded` / `failed` |
|
|
||||||
|
|
||||||
## Auth-Slice
|
|
||||||
|
|
||||||
- **State:** `user`, `googleClientId`, `status`
|
|
||||||
- **Setup-Gate:** `selectNeedsCompanySetup` → eingeloggt, aber keine `selectedCompanyId` → `CompanySetupDialog`
|
|
||||||
- `mergeUser` behält beim Partial-Update ggf. die bisherige `selectedCompanyId`
|
|
||||||
|
|
||||||
## Company-Slice
|
|
||||||
|
|
||||||
- **State:** `current` (normalisiert über `normalizeCompany`), `status`, `error`
|
|
||||||
- Thunks lesen `auth.user.selectedCompanyId` aus dem Store (`getState`)
|
|
||||||
- Selectors liefern u. a. Name, `iso639`, Steuersätze, `rootCategoryId` für Category/UI
|
|
||||||
|
|
||||||
## Category-Slice
|
|
||||||
|
|
||||||
- **Baum:** `categories` als Id→Node-Map, plus `rootCategoryId`, `rootArticles`, `languageId`
|
|
||||||
- **Navigation:** `currentCategoryId`; Root zeigt `rootArticles`, Unterkategorien laden per `loadCategoryArticles`
|
|
||||||
- **Sync-Actions:** `navigateToCategory`, `navigateBack`, `resetCategoryState`
|
|
||||||
- Hilfsfunktionen (`normalizeCategoryNode`, `parseCategoryTreeResponse`, Breadcrumbs, Slugs) leben im Slice und werden u. a. von `categoryRoute.js` für URL-Pfade wiederverwendet
|
|
||||||
|
|
||||||
## Dev-Logging
|
|
||||||
|
|
||||||
In Development loggt `reduxDevLogger` Actions unter `auth/`, `company/`, `category/` inkl. Payload und kurzem Auth-/Company-Snapshot nach dem Reduce.
|
|
||||||
|
|
||||||
## Was (noch) nicht in Redux liegt
|
|
||||||
|
|
||||||
Artikel-Detail, Fotos und ähnliche lokale UI-Zustände werden in Komponenten / lokalen Props gehalten, nicht als eigener Slice. Neue Domänen folgen dem bestehenden Muster: API in `src/api`, Thunk + Validierung im Slice, Selectors + `connect` in der UI.
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
|
|
||||||
import { ApiClient } from './ApiClient';
|
import { ApiClient } from './ApiClient';
|
||||||
import artikelAutomat from '../automats/artikelAutomat';
|
import artikelAutomat from '../automats/artikelAutomat';
|
||||||
import companyAutomat from '../automats/companyAutomat';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
import brandAutomat from '../automats/brandAutomat';
|
import brandAutomat from '../automats/brandAutomat';
|
||||||
import unitAutomat from '../automats/unitAutomat';
|
import unitAutomat from '../automats/unitAutomat';
|
||||||
import authAutomat from '../automats/authAutomat';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import categoryAutomat from '../automats/categoryAutomat';
|
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
||||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
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.
|
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
|
||||||
* @returns {{ categories: object, rootCategoryId: unknown } | null}
|
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
|
||||||
*/
|
*/
|
||||||
getCategoryState() {
|
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;
|
const catState = categoryAutomat.state;
|
||||||
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
||||||
return {
|
return {
|
||||||
@@ -413,12 +398,6 @@ export class StorageManager extends ApiClient {
|
|||||||
);
|
);
|
||||||
const articleIndexCache = this.#articleIndexCache;
|
const articleIndexCache = this.#articleIndexCache;
|
||||||
|
|
||||||
// category tree: deferred, reloaded via categoryAutomat
|
|
||||||
const categoryTreeCache = this.#createDeferredCache(
|
|
||||||
'GET /category/tree',
|
|
||||||
() => this.#loadCategoryTree(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Object.defineProperties(this, {
|
Object.defineProperties(this, {
|
||||||
authMePromise: { get: () => authMeCache.promise },
|
authMePromise: { get: () => authMeCache.promise },
|
||||||
updateAuthMe: { value: authMeCache.update },
|
updateAuthMe: { value: authMeCache.update },
|
||||||
@@ -499,11 +478,22 @@ export class StorageManager extends ApiClient {
|
|||||||
// Return a single-element array so .find() still works.
|
// Return a single-element array so .find() still works.
|
||||||
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
|
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
|
||||||
|
|
||||||
// ── Category tree bridge ─────────────────────────────────────────────────
|
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
|
||||||
categoryTreePromise: { get: () => categoryTreeCache.promise },
|
/** Category tree promise resolving when categoryAutomat tree is loaded. */
|
||||||
updateCategoryTree: { value: categoryTreeCache.update },
|
categoryTreePromise: {
|
||||||
/** Settled GET /category/tree body, or undefined. */
|
get: () => categoryAutomat.state.treeStatus === 'succeeded'
|
||||||
categoryTree: { get: () => categoryTreeCache.value },
|
? 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) ─────────────────────────────────────────
|
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
|
||||||
deliveryPromise: { get: () => deliveryCache.promise },
|
deliveryPromise: { get: () => deliveryCache.promise },
|
||||||
@@ -556,7 +546,6 @@ export class StorageManager extends ApiClient {
|
|||||||
// Unauthenticated or logged out
|
// Unauthenticated or logged out
|
||||||
this.#disconnectEvents();
|
this.#disconnectEvents();
|
||||||
articleIndexCache.clear();
|
articleIndexCache.clear();
|
||||||
categoryTreeCache.clear();
|
|
||||||
deliveryCache.clear();
|
deliveryCache.clear();
|
||||||
customergroupCache.clear();
|
customergroupCache.clear();
|
||||||
entityCache.clear();
|
entityCache.clear();
|
||||||
@@ -568,7 +557,6 @@ export class StorageManager extends ApiClient {
|
|||||||
// Company established or switched
|
// Company established or switched
|
||||||
if (prevCompanyId) {
|
if (prevCompanyId) {
|
||||||
articleIndexCache.clear();
|
articleIndexCache.clear();
|
||||||
categoryTreeCache.clear();
|
|
||||||
deliveryCache.clear();
|
deliveryCache.clear();
|
||||||
customergroupCache.clear();
|
customergroupCache.clear();
|
||||||
entityCache.clear();
|
entityCache.clear();
|
||||||
@@ -678,11 +666,13 @@ export class StorageManager extends ApiClient {
|
|||||||
this.#disconnectEvents();
|
this.#disconnectEvents();
|
||||||
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
||||||
es.addEventListener('category', () => {
|
es.addEventListener('category', () => {
|
||||||
void this.updateCategoryTree().catch((err) => {
|
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||||
console.error('[storageMgr] category SSE refresh failed', err);
|
console.error('[storageMgr] category tree SSE refresh failed', err);
|
||||||
});
|
});
|
||||||
|
invalidateAllCategoryViews();
|
||||||
});
|
});
|
||||||
es.addEventListener('articles', () => {
|
es.addEventListener('articles', () => {
|
||||||
|
invalidateAllCategoryViews();
|
||||||
void this.updateArticleIndex().catch((err) => {
|
void this.updateArticleIndex().catch((err) => {
|
||||||
console.error('[storageMgr] articles SSE refresh failed', 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() {
|
async #loadDeliveries() {
|
||||||
const data = await this.delivery.list();
|
const data = await this.delivery.list();
|
||||||
requireObject(data, 'GET /api/delivery');
|
requireObject(data, 'GET /api/delivery');
|
||||||
|
|||||||
@@ -16,13 +16,8 @@ import {
|
|||||||
* @property {string|null} languageId
|
* @property {string|null} languageId
|
||||||
* @property {Record<string,object>|null} categories id → node map
|
* @property {Record<string,object>|null} categories id → node map
|
||||||
* @property {number|string|null} rootCategoryId
|
* @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 {boolean} renameMode
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
|
|
||||||
* @property {string|null} error
|
* @property {string|null} error
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -32,25 +27,117 @@ const initialState = {
|
|||||||
languageId: null,
|
languageId: null,
|
||||||
categories: null,
|
categories: null,
|
||||||
rootCategoryId: null,
|
rootCategoryId: null,
|
||||||
rootArticles: [],
|
|
||||||
currentCategoryId: null,
|
|
||||||
articles: [],
|
|
||||||
articlesTotal: 0,
|
|
||||||
renameMode: false,
|
renameMode: false,
|
||||||
treeStatus: 'idle',
|
treeStatus: 'idle',
|
||||||
articlesStatus: 'idle',
|
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let categoryAutomat;
|
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)) {
|
* Loader for category view automat: fetches articles for categoryId.
|
||||||
return state.rootArticles;
|
* 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 ───────────────────────────────────────────────────────────────────
|
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||||
@@ -73,11 +160,6 @@ const actions = {
|
|||||||
languageId: parsed.languageId,
|
languageId: parsed.languageId,
|
||||||
categories: parsed.categories,
|
categories: parsed.categories,
|
||||||
rootCategoryId: parsed.rootCategoryId,
|
rootCategoryId: parsed.rootCategoryId,
|
||||||
currentCategoryId: parsed.rootCategoryId,
|
|
||||||
rootArticles: parsed.rootArticles,
|
|
||||||
articles: parsed.rootArticles,
|
|
||||||
articlesTotal: parsed.rootArticles.length,
|
|
||||||
articlesStatus: 'succeeded',
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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. */
|
/** Toggle rename/edit mode for the category grid. */
|
||||||
setRenameMode(enabled) {
|
setRenameMode(enabled) {
|
||||||
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
||||||
@@ -192,17 +218,17 @@ const actions = {
|
|||||||
async createArticle({ parentId, name }) {
|
async createArticle({ parentId, name }) {
|
||||||
const res = await articleApi.create({ parentId, name });
|
const res = await articleApi.create({ parentId, name });
|
||||||
const created = res.article;
|
const created = res.article;
|
||||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
if (created && parentId != null) {
|
||||||
const patch = {};
|
const routeName = categoryIdToRouteName.get(String(parentId));
|
||||||
if (String(created.parentId) === String(currentCategoryId)) {
|
if (routeName) {
|
||||||
patch.articles = [...articles, created];
|
const routeAutomat = Automat.get(routeName);
|
||||||
patch.articlesTotal = patch.articles.length;
|
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
|
||||||
patch.articlesStatus = 'succeeded';
|
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;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -218,19 +244,17 @@ const actions = {
|
|||||||
const res = await articleApi.create({ parentId, name });
|
const res = await articleApi.create({ parentId, name });
|
||||||
if (res.article) created.push(res.article);
|
if (res.article) created.push(res.article);
|
||||||
}
|
}
|
||||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
if (created.length > 0 && parentId != null) {
|
||||||
const patch = {};
|
const routeName = categoryIdToRouteName.get(String(parentId));
|
||||||
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
|
if (routeName) {
|
||||||
if (forCurrent.length > 0) {
|
const routeAutomat = Automat.get(routeName);
|
||||||
patch.articles = [...articles, ...forCurrent];
|
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
|
||||||
patch.articlesTotal = patch.articles.length;
|
routeAutomat.setState({
|
||||||
patch.articlesStatus = 'succeeded';
|
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;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -239,34 +263,32 @@ const actions = {
|
|||||||
* @param {{ categoryId: number|string, name: string }} param0
|
* @param {{ categoryId: number|string, name: string }} param0
|
||||||
*/
|
*/
|
||||||
async renameCategory({ categoryId, name }) {
|
async renameCategory({ categoryId, name }) {
|
||||||
|
if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) {
|
||||||
|
throw new Error('Home-Kategorie kann nicht umbenannt werden.');
|
||||||
|
}
|
||||||
const trimmed = String(name ?? '').trim();
|
const trimmed = String(name ?? '').trim();
|
||||||
if (!trimmed) throw new Error('Name required');
|
if (!trimmed) throw new Error('Name required');
|
||||||
const res = await categoryApi.updateName(categoryId, trimmed);
|
const res = await categoryApi.updateName(categoryId, trimmed);
|
||||||
const updated = normalizeCategoryNode(res.category);
|
const updated = normalizeCategoryNode(res.category);
|
||||||
const key = String(updated.id);
|
const key = String(updated.id);
|
||||||
const { categories, rootArticles } = categoryAutomat.state;
|
const { categories } = categoryAutomat.state;
|
||||||
categoryAutomat.setState({
|
categoryAutomat.setState({
|
||||||
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
|
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;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder sibling categories.
|
* Reorder sibling categories.
|
||||||
* @param {{ categoryIds: number[] }} param0
|
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
|
||||||
*/
|
*/
|
||||||
async reorderCategories({ categoryIds }) {
|
async reorderCategories({ parentId, categoryIds }) {
|
||||||
const { currentCategoryId, categories } = categoryAutomat.state;
|
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
|
||||||
const res = await categoryApi.reorder(currentCategoryId, categoryIds);
|
const res = await categoryApi.reorder(targetParentId, categoryIds);
|
||||||
const ordered = res.categoryIds;
|
const ordered = res.categoryIds;
|
||||||
const parentKey = String(currentCategoryId);
|
const parentKey = String(targetParentId);
|
||||||
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
|
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
|
||||||
const patched = { ...categories };
|
const patched = { ...categoryAutomat.state.categories };
|
||||||
for (const node of Object.values(patched)) {
|
for (const node of Object.values(patched)) {
|
||||||
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
|
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
|
||||||
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
|
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
|
||||||
@@ -301,18 +323,11 @@ const actions = {
|
|||||||
*/
|
*/
|
||||||
async deleteCategory(categoryId) {
|
async deleteCategory(categoryId) {
|
||||||
await categoryApi.delete(categoryId);
|
await categoryApi.delete(categoryId);
|
||||||
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
|
const { categories } = categoryAutomat.state;
|
||||||
if (!categories) return;
|
if (!categories) return;
|
||||||
const next = { ...categories };
|
const next = { ...categories };
|
||||||
delete next[String(categoryId)];
|
delete next[String(categoryId)];
|
||||||
const patch = { categories: next };
|
categoryAutomat.setState({ categories: next });
|
||||||
if (String(currentCategoryId) === String(categoryId)) {
|
|
||||||
patch.currentCategoryId = rootCategoryId;
|
|
||||||
patch.articles = rootArticles;
|
|
||||||
patch.articlesTotal = rootArticles.length;
|
|
||||||
patch.articlesStatus = 'succeeded';
|
|
||||||
}
|
|
||||||
categoryAutomat.setState(patch);
|
|
||||||
return categoryId;
|
return categoryId;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -369,55 +384,17 @@ const actions = {
|
|||||||
|
|
||||||
// ─── Local state patches ───────────────────────────────────────────────────
|
// ─── 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) {
|
removeArticleBySku(sku) {
|
||||||
categoryAutomat.setState({
|
if (!sku) return;
|
||||||
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
|
for (const automat of categoryViewInstances) {
|
||||||
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
|
const articles = automat.state?.articles;
|
||||||
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
|
if (Array.isArray(articles) && articles.some((r) => r.sku === sku)) {
|
||||||
|
automat.setState({
|
||||||
|
articles: articles.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryAutomat.setState({
|
|
||||||
articles: nextArticles,
|
|
||||||
articlesTotal: nextArticlesTotal,
|
|
||||||
rootArticles: nextRoot,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -442,6 +419,7 @@ const actions = {
|
|||||||
|
|
||||||
/** Reset state to idle (logout / company switch). */
|
/** Reset state to idle (logout / company switch). */
|
||||||
reset() {
|
reset() {
|
||||||
|
disposeAllCategoryViewAutomats();
|
||||||
categoryAutomat.setState(initialState);
|
categoryAutomat.setState(initialState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -461,6 +439,7 @@ companyAutomat.subscribe((company) => {
|
|||||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||||
_lastCompanyId = id;
|
_lastCompanyId = id;
|
||||||
|
disposeAllCategoryViewAutomats();
|
||||||
void categoryAutomat.actions.loadTree();
|
void categoryAutomat.actions.loadTree();
|
||||||
}
|
}
|
||||||
if (!id && _lastCompanyId) {
|
if (!id && _lastCompanyId) {
|
||||||
|
|||||||
@@ -51,4 +51,8 @@ export {
|
|||||||
categoryDisplayName,
|
categoryDisplayName,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
normalizeCategoryMap,
|
normalizeCategoryMap,
|
||||||
|
getOrCreateCategoryAutomat,
|
||||||
|
disposeAllCategoryViewAutomats,
|
||||||
|
invalidateAllCategoryViews,
|
||||||
} from './categoryAutomat';
|
} from './categoryAutomat';
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +1,280 @@
|
|||||||
import { useState } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import List from '@mui/material/List';
|
import Box from '@mui/material/Box';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import Typography from '@mui/material/Typography';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Collapse from '@mui/material/Collapse';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import FolderIcon from '@mui/icons-material/Folder';
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
import Breadcrumbs from '@mui/material/Breadcrumbs';
|
||||||
import Box from '@mui/material/Box';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
|
|
||||||
function CategoryNode({
|
import CategoryTree from '../category/CategoryTree';
|
||||||
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
|
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../utils/categoryUtils';
|
||||||
|
|
||||||
|
const EMPTY_ARRAY = [];
|
||||||
|
|
||||||
|
export default class CategoryPickerDialog extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const categories = props.categories ?? categoryAutomat.state.categories;
|
||||||
|
const rootCategoryId = props.rootCategoryId ?? categoryAutomat.state.rootCategoryId;
|
||||||
|
const currentCategoryIds = props.currentCategoryIds ?? EMPTY_ARRAY;
|
||||||
|
|
||||||
|
let selectedId = null;
|
||||||
|
if (props.selectedId != null) {
|
||||||
|
selectedId = props.selectedId;
|
||||||
|
} else if (currentCategoryIds.length > 0) {
|
||||||
|
selectedId = currentCategoryIds[0];
|
||||||
|
} else if (rootCategoryId != null) {
|
||||||
|
selectedId = rootCategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
categories,
|
categories,
|
||||||
categoryId,
|
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
};
|
||||||
expandedIds,
|
}
|
||||||
onToggleExpand,
|
|
||||||
depth = 0,
|
|
||||||
}) {
|
|
||||||
const node = categories[String(categoryId)];
|
|
||||||
if (!node) return null;
|
|
||||||
|
|
||||||
const children = getCategoryChildren(categories, categoryId);
|
componentDidMount() {
|
||||||
const hasChildren = children.length > 0;
|
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
||||||
const isSelected = selectedId != null && String(selectedId) === String(categoryId);
|
this._unsub = categoryAutomat.subscribe((s) => {
|
||||||
const isExpanded = expandedIds.has(categoryId);
|
this.setState((prev) => ({
|
||||||
const label = categoryDisplayName(node.name, node.id, rootCategoryId);
|
categories: this.props.categories ?? s.categories,
|
||||||
|
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
||||||
return (
|
}));
|
||||||
<>
|
});
|
||||||
<ListItemButton
|
|
||||||
selected={isSelected}
|
|
||||||
onClick={() => onSelect(categoryId)}
|
|
||||||
sx={{ pl: 1 + depth * 1.5 }}
|
|
||||||
>
|
|
||||||
<Box sx={{ minWidth: 24, display: 'flex', alignItems: 'center' }}>
|
|
||||||
{hasChildren ? (
|
|
||||||
isExpanded ? <FolderOpenIcon fontSize="small" /> : <FolderIcon fontSize="small" />
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
<ListItemText primary={label} />
|
|
||||||
{isSelected && <CheckIcon fontSize="small" color="primary" sx={{ ml: 1 }} />}
|
|
||||||
{hasChildren && (
|
|
||||||
isExpanded ? (
|
|
||||||
<ExpandLess onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, false); }} />
|
|
||||||
) : (
|
|
||||||
<ExpandMore onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, true); }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ListItemButton>
|
|
||||||
{hasChildren && (
|
|
||||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
|
||||||
<List disablePadding>
|
|
||||||
{children.map((child) => (
|
|
||||||
<CategoryNode
|
|
||||||
key={child.id}
|
|
||||||
categories={categories}
|
|
||||||
categoryId={child.id}
|
|
||||||
rootCategoryId={rootCategoryId}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={onSelect}
|
|
||||||
expandedIds={expandedIds}
|
|
||||||
onToggleExpand={onToggleExpand}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds) {
|
|
||||||
const ids = new Set();
|
|
||||||
if (!categories || !rootCategoryId) return ids;
|
|
||||||
for (const catId of currentCategoryIds ?? []) {
|
|
||||||
let id = catId;
|
|
||||||
while (id != null) {
|
|
||||||
const node = categories[String(id)];
|
|
||||||
if (!node) break;
|
|
||||||
ids.add(id);
|
|
||||||
if (node.parentId == null || node.parentId === 0) break;
|
|
||||||
id = node.parentId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CategoryPickerDialog({
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.open && !prevProps.open) {
|
||||||
|
const currentCategoryIds = this.props.currentCategoryIds ?? EMPTY_ARRAY;
|
||||||
|
let nextSelectedId = null;
|
||||||
|
if (this.props.selectedId != null) {
|
||||||
|
nextSelectedId = this.props.selectedId;
|
||||||
|
} else if (currentCategoryIds.length > 0) {
|
||||||
|
nextSelectedId = currentCategoryIds[0];
|
||||||
|
} else if (this.state.rootCategoryId != null) {
|
||||||
|
nextSelectedId = this.state.rootCategoryId;
|
||||||
|
}
|
||||||
|
this.setState({ selectedId: nextSelectedId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.props.categories !== prevProps.categories ||
|
||||||
|
this.props.rootCategoryId !== prevProps.rootCategoryId
|
||||||
|
) {
|
||||||
|
this.setState({
|
||||||
|
categories: this.props.categories ?? categoryAutomat.state.categories,
|
||||||
|
rootCategoryId: this.props.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelect = (id) => {
|
||||||
|
this.setState({ selectedId: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDoubleClick = (id) => {
|
||||||
|
this.handleConfirmWithId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirmWithId = (targetId) => {
|
||||||
|
if (targetId == null) return;
|
||||||
|
const { disabledIds = EMPTY_ARRAY, excludeIds = EMPTY_ARRAY, mode = 'assign', onSelect, onSelectCategory, onClose } = this.props;
|
||||||
|
if (disabledIds.some((id) => String(id) === String(targetId))) return;
|
||||||
|
if (excludeIds.some((id) => String(id) === String(targetId))) return;
|
||||||
|
|
||||||
|
const { categories } = this.state;
|
||||||
|
const targetNode = categories ? categories[String(targetId)] : null;
|
||||||
|
if (mode === 'single') {
|
||||||
|
onSelectCategory?.(targetId, targetNode);
|
||||||
|
onSelect?.(targetId, targetNode);
|
||||||
|
} else {
|
||||||
|
const currentCategoryIds = this.props.currentCategoryIds ?? EMPTY_ARRAY;
|
||||||
|
const ids = currentCategoryIds.filter((id) => String(id) !== String(targetId));
|
||||||
|
ids.push(targetId);
|
||||||
|
onSelectCategory?.(targetId, targetNode);
|
||||||
|
onSelect?.(ids);
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirm = () => {
|
||||||
|
this.handleConfirmWithId(this.state.selectedId);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
categories,
|
currentCategoryIds = EMPTY_ARRAY,
|
||||||
rootCategoryId,
|
title,
|
||||||
currentCategoryIds,
|
confirmLabel,
|
||||||
onSelect,
|
mode = 'assign',
|
||||||
}) {
|
disabledIds = EMPTY_ARRAY,
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
excludeIds = EMPTY_ARRAY,
|
||||||
const [expandedIds, setExpandedIds] = useState(
|
} = this.props;
|
||||||
() => buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleExpand = (id, expand) => {
|
const { categories, rootCategoryId, selectedId } = this.state;
|
||||||
setExpandedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (expand) next.add(id);
|
|
||||||
else next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const dialogTitle = title || (mode === 'single' ? 'Kategorie auswählen' : 'Kategorie zuweisen');
|
||||||
if (selectedId == null) return;
|
const actionLabel = confirmLabel || (mode === 'single' ? 'Auswählen' : 'Zuweisen');
|
||||||
const categoryIds = currentCategoryIds.filter((id) => String(id) !== String(selectedId));
|
|
||||||
categoryIds.push(selectedId);
|
const selectedNode = categories && selectedId != null ? categories[String(selectedId)] : null;
|
||||||
onSelect?.(categoryIds);
|
const isAlreadyAssigned =
|
||||||
onClose();
|
mode !== 'single' && currentCategoryIds.some((id) => String(id) === String(selectedId));
|
||||||
};
|
const isSelectedDisabled =
|
||||||
|
selectedId == null ||
|
||||||
|
disabledIds.some((id) => String(id) === String(selectedId)) ||
|
||||||
|
excludeIds.some((id) => String(id) === String(selectedId));
|
||||||
|
|
||||||
|
const breadcrumbs =
|
||||||
|
categories && selectedId != null
|
||||||
|
? getCategoryBreadcrumbs(categories, selectedId, rootCategoryId)
|
||||||
|
: EMPTY_ARRAY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
<Dialog
|
||||||
<DialogTitle>Kategorie zuweisen</DialogTitle>
|
open={open}
|
||||||
<DialogContent dividers sx={{ p: 0 }}>
|
onClose={onClose}
|
||||||
{categories && rootCategoryId != null ? (
|
maxWidth="sm"
|
||||||
<List dense disablePadding>
|
fullWidth
|
||||||
<CategoryNode
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
height: '75vh',
|
||||||
|
minHeight: '75vh',
|
||||||
|
maxHeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 2.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
height: '75vh',
|
||||||
|
minHeight: '75vh',
|
||||||
|
maxHeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 2.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
pb: 1.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<FolderIcon color="primary" />
|
||||||
|
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
|
||||||
|
{dialogTitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={onClose} aria-label="Schließen">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Breadcrumb Path Preview */}
|
||||||
|
{breadcrumbs.length > 0 && (
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon sx={{ fontSize: 14 }} />}
|
||||||
|
sx={{ mt: 1, '& .MuiBreadcrumbs-li': { fontSize: '0.8rem' } }}
|
||||||
|
>
|
||||||
|
{breadcrumbs.map((bc, idx) => {
|
||||||
|
const isLast = idx === breadcrumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
key={bc.id}
|
||||||
|
variant="caption"
|
||||||
|
color={isLast ? 'primary.main' : 'text.secondary'}
|
||||||
|
fontWeight={isLast ? 600 : 400}
|
||||||
|
>
|
||||||
|
{categoryDisplayName(bc.name, bc.id, rootCategoryId)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Breadcrumbs>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{/* Tree Content */}
|
||||||
|
<DialogContent sx={{ p: 0, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
<CategoryTree
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryId={rootCategoryId}
|
|
||||||
rootCategoryId={rootCategoryId}
|
rootCategoryId={rootCategoryId}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={this.handleSelect}
|
||||||
expandedIds={expandedIds}
|
onDoubleClick={this.handleDoubleClick}
|
||||||
onToggleExpand={handleToggleExpand}
|
alreadyAssignedIds={currentCategoryIds}
|
||||||
|
disabledIds={disabledIds}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
allowAdd={true}
|
||||||
|
allowDelete={true}
|
||||||
|
allowReorder={true}
|
||||||
|
allowRename={true}
|
||||||
|
showSearch={true}
|
||||||
|
sx={{ height: '100%' }}
|
||||||
/>
|
/>
|
||||||
</List>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ p: 2, textAlign: 'center', color: 'text.secondary' }}>
|
|
||||||
Lade Kategorien…
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Abbrechen</Button>
|
{/* Footer / Actions */}
|
||||||
<Button onClick={handleConfirm} disabled={selectedId == null} variant="contained">
|
<DialogActions
|
||||||
Zuweisen
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: isAlreadyAssigned ? 'space-between' : 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAlreadyAssigned && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
(bereits zugewiesen)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button onClick={onClose} color="inherit">
|
||||||
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<CheckIcon />}
|
||||||
|
onClick={this.handleConfirm}
|
||||||
|
disabled={isSelectedDisabled}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class NewArticleTile extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { parentId, disabled } = this.props;
|
const { parentId, disabled, sx } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,6 +48,7 @@ class NewArticleTile extends Component {
|
|||||||
label="Neuer Artikel"
|
label="Neuer Artikel"
|
||||||
disabled={disabled || parentId == null}
|
disabled={disabled || parentId == null}
|
||||||
onClick={this.handleOpen}
|
onClick={this.handleOpen}
|
||||||
|
sx={sx}
|
||||||
/>
|
/>
|
||||||
<ArticleNameDialog
|
<ArticleNameDialog
|
||||||
open={this.state.open}
|
open={this.state.open}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
|
|||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||||
import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
|
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||||
import { Box, Chip, IconButton } from '@mui/material';
|
import { Box, Chip, IconButton } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CategoryPickerDialog from '../CategoryPickerDialog';
|
import CategoryPickerDialog from '../CategoryPickerDialog';
|
||||||
@@ -92,7 +92,7 @@ class CategoryPills extends Component {
|
|||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { pickerOpen: false };
|
this.state = { pickerOpen: false, targetPillId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoveCategory = (removeId) => {
|
handleRemoveCategory = (removeId) => {
|
||||||
@@ -101,20 +101,52 @@ class CategoryPills extends Component {
|
|||||||
onCategoryIdsChange?.(remaining);
|
onCategoryIdsChange?.(remaining);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOpenPicker = () => {
|
handleOpenAddPicker = () => {
|
||||||
this.setState({ pickerOpen: true });
|
this.setState({ pickerOpen: true, targetPillId: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOpenMovePicker = (pillId) => {
|
||||||
|
this.setState({ pickerOpen: true, targetPillId: pillId });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClosePicker = () => {
|
handleClosePicker = () => {
|
||||||
this.setState({ pickerOpen: false });
|
this.setState({ pickerOpen: false, targetPillId: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCategorySelect = (selectedCategoryId) => {
|
||||||
|
const { categoryIds, onCategoryIdsChange } = this.props;
|
||||||
|
const { targetPillId } = this.state;
|
||||||
|
|
||||||
|
if (targetPillId != null) {
|
||||||
|
const updated = categoryIds.map((id) =>
|
||||||
|
String(id) === String(targetPillId) ? selectedCategoryId : id
|
||||||
|
);
|
||||||
|
const deduped = [...new Set(updated)];
|
||||||
|
onCategoryIdsChange?.(deduped);
|
||||||
|
} else {
|
||||||
|
if (!categoryIds.some((id) => String(id) === String(selectedCategoryId))) {
|
||||||
|
onCategoryIdsChange?.([...categoryIds, selectedCategoryId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { categoryIds, editMode, onCategoryIdsChange } = this.props;
|
const { categoryIds, editMode } = this.props;
|
||||||
const { pickerOpen } = this.state;
|
const { pickerOpen, targetPillId } = this.state;
|
||||||
const { categories, rootCategoryId } = this.context.getCategoryState() ?? {};
|
const { categories, rootCategoryId } = this.context.getCategoryState() ?? {};
|
||||||
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||||
|
|
||||||
|
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
||||||
|
const targetLabel = targetNode
|
||||||
|
? categoryDisplayName(targetNode.name, targetNode.id, rootCategoryId)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const dialogTitle =
|
||||||
|
targetPillId != null
|
||||||
|
? (targetLabel ? `Artikel aus Kategorie „${targetLabel}“ verschieben in…` : 'Artikel aus Kategorie verschieben in…')
|
||||||
|
: 'Kategorie hinzufügen';
|
||||||
|
const confirmLabel = targetPillId != null ? 'Hierher verschieben' : 'Hinzufügen';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={pillsContainerSx}>
|
<Box sx={pillsContainerSx}>
|
||||||
@@ -124,17 +156,28 @@ class CategoryPills extends Component {
|
|||||||
label={label}
|
label={label}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
onClick={editMode ? () => this.handleOpenMovePicker(id) : undefined}
|
||||||
onDelete={
|
onDelete={
|
||||||
editMode && categoryIds.length > 1
|
editMode && categoryIds.length > 1
|
||||||
? () => this.handleRemoveCategory(id)
|
? () => this.handleRemoveCategory(id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
sx={{
|
||||||
|
cursor: editMode ? 'pointer' : 'default',
|
||||||
|
...(editMode
|
||||||
|
? {
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={this.handleOpenPicker}
|
onClick={this.handleOpenAddPicker}
|
||||||
sx={{ ml: 0.5, width: 24, height: 24 }}
|
sx={{ ml: 0.5, width: 24, height: 24 }}
|
||||||
>
|
>
|
||||||
<AddIcon fontSize="small" />
|
<AddIcon fontSize="small" />
|
||||||
@@ -148,7 +191,11 @@ class CategoryPills extends Component {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
rootCategoryId={rootCategoryId}
|
rootCategoryId={rootCategoryId}
|
||||||
currentCategoryIds={categoryIds}
|
currentCategoryIds={categoryIds}
|
||||||
onSelect={onCategoryIdsChange}
|
selectedId={targetPillId ?? rootCategoryId}
|
||||||
|
title={dialogTitle}
|
||||||
|
confirmLabel={confirmLabel}
|
||||||
|
mode="single"
|
||||||
|
onSelectCategory={this.handleCategorySelect}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
796
src/components/category/CategoryTree.jsx
Normal file
796
src/components/category/CategoryTree.jsx
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
|
import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
const EMPTY_ARRAY = [];
|
||||||
|
const EMPTY_SET = new Set();
|
||||||
|
|
||||||
|
/** Helper: gather all ancestor IDs of matching categories */
|
||||||
|
function getMatchingAndAncestorIds(categories, query, rootCategoryId, excludeIds = EMPTY_ARRAY) {
|
||||||
|
const matches = new Set();
|
||||||
|
if (!categories || !query.trim()) return matches;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
for (const [idStr, node] of Object.entries(categories)) {
|
||||||
|
if (excludeIds.some((id) => String(id) === String(node.id))) continue;
|
||||||
|
const label = categoryDisplayName(node.name, node.id, rootCategoryId).toLowerCase();
|
||||||
|
if (label.includes(q)) {
|
||||||
|
matches.add(node.id);
|
||||||
|
let parentId = node.parentId;
|
||||||
|
while (parentId != null && parentId !== 0) {
|
||||||
|
if (excludeIds.some((id) => String(id) === String(parentId))) break;
|
||||||
|
matches.add(parentId);
|
||||||
|
const parent = categories[String(parentId)];
|
||||||
|
if (!parent || parent.parentId == null || parent.parentId === 0) break;
|
||||||
|
parentId = parent.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual Category Tree Node - Always Expanded */
|
||||||
|
class CategoryTreeNode extends PureComponent {
|
||||||
|
state = { menuAnchor: null };
|
||||||
|
|
||||||
|
handleOpenMenu = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({ menuAnchor: e.currentTarget });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseMenu = () => {
|
||||||
|
this.setState({ menuAnchor: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelect = () => {
|
||||||
|
const { categoryId, categories, onSelect, disabledIds = EMPTY_ARRAY } = this.props;
|
||||||
|
const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
|
||||||
|
if (!isDisabled) {
|
||||||
|
const node = categories?.[String(categoryId)];
|
||||||
|
onSelect?.(categoryId, node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDoubleClick = () => {
|
||||||
|
const { categoryId, categories, onDoubleClick, disabledIds = EMPTY_ARRAY } = this.props;
|
||||||
|
const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
|
||||||
|
if (!isDisabled) {
|
||||||
|
const node = categories?.[String(categoryId)];
|
||||||
|
onDoubleClick?.(categoryId, node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
categoryId,
|
||||||
|
rootCategoryId,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onDoubleClick,
|
||||||
|
searchMatches,
|
||||||
|
query,
|
||||||
|
depth = 0,
|
||||||
|
alreadyAssignedIds = EMPTY_ARRAY,
|
||||||
|
disabledIds = EMPTY_ARRAY,
|
||||||
|
excludeIds = EMPTY_ARRAY,
|
||||||
|
allowAdd = true,
|
||||||
|
allowDelete = true,
|
||||||
|
allowReorder = true,
|
||||||
|
allowRename = true,
|
||||||
|
onOpenAdd,
|
||||||
|
onOpenRename,
|
||||||
|
onOpenDelete,
|
||||||
|
onMoveSibling,
|
||||||
|
isFirstSibling = false,
|
||||||
|
isLastSibling = false,
|
||||||
|
renderItemBadge,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (excludeIds.some((id) => String(id) === String(categoryId))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = categories?.[String(categoryId)];
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const isRoot = rootCategoryId != null && String(categoryId) === String(rootCategoryId);
|
||||||
|
const children = getCategoryChildren(categories, categoryId).filter(
|
||||||
|
(c) => !excludeIds.some((id) => String(id) === String(c.id)),
|
||||||
|
);
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
const isSelected = selectedId != null && String(selectedId) === String(categoryId);
|
||||||
|
const isAlreadyAssigned = alreadyAssignedIds.some((id) => String(id) === String(categoryId));
|
||||||
|
const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
|
||||||
|
const label = categoryDisplayName(node.name, node.id, rootCategoryId);
|
||||||
|
|
||||||
|
// If search filter is active and this node is not part of the matching set, skip
|
||||||
|
if (query && searchMatches && !searchMatches.has(categoryId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { menuAnchor } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItemButton
|
||||||
|
disableRipple
|
||||||
|
selected={isSelected}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={this.handleSelect}
|
||||||
|
onDoubleClick={this.handleDoubleClick}
|
||||||
|
sx={{
|
||||||
|
py: 0.75,
|
||||||
|
px: 1,
|
||||||
|
pl: 1 + depth * 2.2,
|
||||||
|
borderRadius: 1,
|
||||||
|
mx: 0.5,
|
||||||
|
my: 0.25,
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
opacity: isDisabled ? 0.4 : 1,
|
||||||
|
'&:hover .category-actions': {
|
||||||
|
opacity: isDisabled ? 0 : 1,
|
||||||
|
pointerEvents: isDisabled ? 'none' : 'auto',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: 'action.selected',
|
||||||
|
fontWeight: 600,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.selected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Folder icon */}
|
||||||
|
<ListItemIcon sx={{ minWidth: 28, color: isSelected ? 'primary.main' : 'text.secondary' }}>
|
||||||
|
<FolderIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: isSelected ? 600 : 400,
|
||||||
|
color: isSelected ? 'primary.main' : 'text.primary',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Child count chip */}
|
||||||
|
{hasChildren && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'text.disabled',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
px: 0.6,
|
||||||
|
py: 0.1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children.length}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Already assigned badge */}
|
||||||
|
{isAlreadyAssigned && (
|
||||||
|
<Chip
|
||||||
|
icon={<CheckCircleIcon sx={{ fontSize: '12px !important' }} />}
|
||||||
|
label="Zugewiesen"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.65rem', '& .MuiChip-label': { px: 0.6 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom badge slot */}
|
||||||
|
{renderItemBadge?.(node)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ my: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons (hover or selected) */}
|
||||||
|
<Box
|
||||||
|
className="category-actions"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.25,
|
||||||
|
opacity: isSelected ? 1 : 0,
|
||||||
|
pointerEvents: isSelected ? 'auto' : 'none',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
ml: 'auto',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Quick reorder buttons */}
|
||||||
|
{allowReorder && !isRoot && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Nach oben verschieben">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disableRipple
|
||||||
|
size="small"
|
||||||
|
disabled={isFirstSibling}
|
||||||
|
onClick={() => onMoveSibling?.(node, 'up')}
|
||||||
|
sx={{ p: 0.3 }}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Nach unten verschieben">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disableRipple
|
||||||
|
size="small"
|
||||||
|
disabled={isLastSibling}
|
||||||
|
onClick={() => onMoveSibling?.(node, 'down')}
|
||||||
|
sx={{ p: 0.3 }}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Add Subcategory button */}
|
||||||
|
{allowAdd && (
|
||||||
|
<Tooltip title="Unterkategorie hinzufügen">
|
||||||
|
<IconButton
|
||||||
|
disableRipple
|
||||||
|
size="small"
|
||||||
|
onClick={() => onOpenAdd?.(node)}
|
||||||
|
sx={{ p: 0.3 }}
|
||||||
|
>
|
||||||
|
<AddIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* More actions menu (not available on root category) */}
|
||||||
|
{!isRoot && (allowRename || allowDelete) && (
|
||||||
|
<IconButton disableRipple size="small" onClick={this.handleOpenMenu} sx={{ p: 0.3 }}>
|
||||||
|
<MoreVertIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRoot && (
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={this.handleCloseMenu}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{allowRename && (
|
||||||
|
<MenuItem
|
||||||
|
dense
|
||||||
|
onClick={() => {
|
||||||
|
this.handleCloseMenu();
|
||||||
|
onOpenRename?.(node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Umbenennen
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{allowDelete && (
|
||||||
|
<MenuItem
|
||||||
|
dense
|
||||||
|
onClick={() => {
|
||||||
|
this.handleCloseMenu();
|
||||||
|
onOpenDelete?.(node);
|
||||||
|
}}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 28, color: 'error.main' }}>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Löschen
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
|
||||||
|
{/* Child categories - always expanded */}
|
||||||
|
{hasChildren && (
|
||||||
|
<List disablePadding sx={{ position: 'relative' }}>
|
||||||
|
{/* Subtle guide line on the left */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 12 + depth * 22,
|
||||||
|
top: 0,
|
||||||
|
bottom: 8,
|
||||||
|
width: '1px',
|
||||||
|
bgcolor: 'divider',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children.map((child, idx) => (
|
||||||
|
<CategoryTreeNode
|
||||||
|
key={child.id}
|
||||||
|
categories={categories}
|
||||||
|
categoryId={child.id}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
searchMatches={searchMatches}
|
||||||
|
query={query}
|
||||||
|
depth={depth + 1}
|
||||||
|
alreadyAssignedIds={alreadyAssignedIds}
|
||||||
|
disabledIds={disabledIds}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
allowAdd={allowAdd}
|
||||||
|
allowDelete={allowDelete}
|
||||||
|
allowReorder={allowReorder}
|
||||||
|
allowRename={allowRename}
|
||||||
|
onOpenAdd={onOpenAdd}
|
||||||
|
onOpenRename={onOpenRename}
|
||||||
|
onOpenDelete={onOpenDelete}
|
||||||
|
onMoveSibling={onMoveSibling}
|
||||||
|
isFirstSibling={idx === 0}
|
||||||
|
isLastSibling={idx === children.length - 1}
|
||||||
|
renderItemBadge={renderItemBadge}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared CategoryTree Component (PureComponent, zero hooks, always expanded)
|
||||||
|
*/
|
||||||
|
export default class CategoryTree extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const categories = props.categories ?? categoryAutomat.state.categories;
|
||||||
|
const rootCategoryId = props.rootCategoryId ?? categoryAutomat.state.rootCategoryId;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
categories,
|
||||||
|
rootCategoryId,
|
||||||
|
searchQuery: '',
|
||||||
|
addDialogOpen: false,
|
||||||
|
addParentNode: null,
|
||||||
|
addCategoryName: '',
|
||||||
|
isSubmitting: false,
|
||||||
|
renameDialogOpen: false,
|
||||||
|
renameNode: null,
|
||||||
|
renameValue: '',
|
||||||
|
deleteDialogOpen: false,
|
||||||
|
deleteNode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
||||||
|
this._unsub = categoryAutomat.subscribe((s) => {
|
||||||
|
this.setState({
|
||||||
|
categories: this.props.categories ?? s.categories,
|
||||||
|
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (
|
||||||
|
this.props.categories !== prevProps.categories ||
|
||||||
|
this.props.rootCategoryId !== prevProps.rootCategoryId
|
||||||
|
) {
|
||||||
|
this.setState({
|
||||||
|
categories: this.props.categories ?? categoryAutomat.state.categories,
|
||||||
|
rootCategoryId: this.props.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog state: Add
|
||||||
|
handleOpenAdd = (parentNode) => {
|
||||||
|
const { rootCategoryId, categories } = this.state;
|
||||||
|
this.setState({
|
||||||
|
addParentNode: parentNode || (rootCategoryId ? categories?.[String(rootCategoryId)] : null),
|
||||||
|
addCategoryName: '',
|
||||||
|
addDialogOpen: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirmAdd = async () => {
|
||||||
|
const { addCategoryName, addParentNode } = this.state;
|
||||||
|
const name = addCategoryName.trim();
|
||||||
|
if (!name || !addParentNode) return;
|
||||||
|
this.setState({ isSubmitting: true });
|
||||||
|
try {
|
||||||
|
const created = await categoryAutomat.actions.createCategory({
|
||||||
|
parentId: addParentNode.id,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
|
||||||
|
this.setState({ addDialogOpen: false });
|
||||||
|
this.props.onSelect?.(created.id, created);
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dialog state: Rename
|
||||||
|
handleOpenRename = (node) => {
|
||||||
|
const { rootCategoryId } = this.state;
|
||||||
|
if (!node || (rootCategoryId != null && String(node.id) === String(rootCategoryId))) return;
|
||||||
|
this.setState({
|
||||||
|
renameNode: node,
|
||||||
|
renameValue: node.name || '',
|
||||||
|
renameDialogOpen: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirmRename = async () => {
|
||||||
|
const { renameNode, renameValue } = this.state;
|
||||||
|
const name = renameValue.trim();
|
||||||
|
if (!name || !renameNode) return;
|
||||||
|
this.setState({ isSubmitting: true });
|
||||||
|
try {
|
||||||
|
await categoryAutomat.actions.renameCategory({
|
||||||
|
categoryId: renameNode.id,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
|
||||||
|
this.setState({ renameDialogOpen: false });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dialog state: Delete
|
||||||
|
handleOpenDelete = (node) => {
|
||||||
|
this.setState({
|
||||||
|
deleteNode: node,
|
||||||
|
deleteDialogOpen: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirmDelete = async () => {
|
||||||
|
const { deleteNode, rootCategoryId } = this.state;
|
||||||
|
if (!deleteNode) return;
|
||||||
|
this.setState({ isSubmitting: true });
|
||||||
|
try {
|
||||||
|
await categoryAutomat.actions.deleteCategory(deleteNode.id);
|
||||||
|
enqueueSnackbar(`Kategorie „${deleteNode.name}“ gelöscht.`, { variant: 'success' });
|
||||||
|
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
|
||||||
|
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
|
||||||
|
}
|
||||||
|
this.setState({ deleteDialogOpen: false });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reordering siblings (up/down)
|
||||||
|
handleMoveSibling = async (node, direction) => {
|
||||||
|
const { categories } = this.state;
|
||||||
|
if (!categories || node.parentId == null) return;
|
||||||
|
const siblings = getCategoryChildren(categories, node.parentId);
|
||||||
|
const currentIndex = siblings.findIndex((s) => String(s.id) === String(node.id));
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= siblings.length) return;
|
||||||
|
|
||||||
|
const reordered = [...siblings];
|
||||||
|
const [moved] = reordered.splice(currentIndex, 1);
|
||||||
|
reordered.splice(targetIndex, 0, moved);
|
||||||
|
|
||||||
|
const categoryIds = reordered.map((s) => s.id);
|
||||||
|
try {
|
||||||
|
await categoryAutomat.actions.reorderCategories({
|
||||||
|
parentId: node.parentId,
|
||||||
|
categoryIds,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onDoubleClick,
|
||||||
|
alreadyAssignedIds = EMPTY_ARRAY,
|
||||||
|
disabledIds = EMPTY_ARRAY,
|
||||||
|
excludeIds = EMPTY_ARRAY,
|
||||||
|
allowAdd = true,
|
||||||
|
allowDelete = true,
|
||||||
|
allowReorder = true,
|
||||||
|
allowRename = true,
|
||||||
|
showSearch = true,
|
||||||
|
renderItemBadge,
|
||||||
|
sx = {},
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
rootCategoryId,
|
||||||
|
searchQuery,
|
||||||
|
addDialogOpen,
|
||||||
|
addParentNode,
|
||||||
|
addCategoryName,
|
||||||
|
isSubmitting,
|
||||||
|
renameDialogOpen,
|
||||||
|
renameNode,
|
||||||
|
renameValue,
|
||||||
|
deleteDialogOpen,
|
||||||
|
deleteNode,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const searchMatches = searchQuery.trim()
|
||||||
|
? getMatchingAndAncestorIds(categories, searchQuery, rootCategoryId, excludeIds)
|
||||||
|
: EMPTY_SET;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, ...sx }}>
|
||||||
|
{/* Search */}
|
||||||
|
{showSearch && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 1.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Kategorie suchen…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => this.setState({ searchQuery: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: searchQuery ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" onClick={() => this.setState({ searchQuery: '' })}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
}}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tree Content - Always Expanded */}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', p: 1 }}>
|
||||||
|
{categories && rootCategoryId != null ? (
|
||||||
|
<List dense disablePadding>
|
||||||
|
<CategoryTreeNode
|
||||||
|
categories={categories}
|
||||||
|
categoryId={rootCategoryId}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
searchMatches={searchMatches}
|
||||||
|
query={searchQuery}
|
||||||
|
alreadyAssignedIds={alreadyAssignedIds}
|
||||||
|
disabledIds={disabledIds}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
allowAdd={allowAdd}
|
||||||
|
allowDelete={allowDelete}
|
||||||
|
allowReorder={allowReorder}
|
||||||
|
allowRename={allowRename}
|
||||||
|
onOpenAdd={this.handleOpenAdd}
|
||||||
|
onOpenRename={this.handleOpenRename}
|
||||||
|
onOpenDelete={this.handleOpenDelete}
|
||||||
|
onMoveSibling={this.handleMoveSibling}
|
||||||
|
renderItemBadge={renderItemBadge}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
|
||||||
|
Lade Kategorien…
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Add Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
onClose={() => !isSubmitting && this.setState({ addDialogOpen: false })}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Neue Unterkategorie anlegen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ mb: 2 }}>
|
||||||
|
Unterkategorie wird erstellt in:{' '}
|
||||||
|
<strong>
|
||||||
|
{addParentNode
|
||||||
|
? categoryDisplayName(addParentNode.name, addParentNode.id, rootCategoryId)
|
||||||
|
: 'Home'}
|
||||||
|
</strong>
|
||||||
|
</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
label="Kategoriename"
|
||||||
|
value={addCategoryName}
|
||||||
|
onChange={(e) => this.setState({ addCategoryName: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && addCategoryName.trim() && !isSubmitting) {
|
||||||
|
e.preventDefault();
|
||||||
|
void this.handleConfirmAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setState({ addDialogOpen: false })}
|
||||||
|
color="inherit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleConfirmAdd}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!addCategoryName.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Rename Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={renameDialogOpen}
|
||||||
|
onClose={() => !isSubmitting && this.setState({ renameDialogOpen: false })}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Kategorie umbenennen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
label="Neuer Name"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => this.setState({ renameValue: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && renameValue.trim() && !isSubmitting) {
|
||||||
|
e.preventDefault();
|
||||||
|
void this.handleConfirmRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setState({ renameDialogOpen: false })}
|
||||||
|
color="inherit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleConfirmRename}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!renameValue.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => !isSubmitting && this.setState({ deleteDialogOpen: false })}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Kategorie löschen?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Möchten Sie die Kategorie „{deleteNode?.name}“ wirklich löschen?
|
||||||
|
{deleteNode &&
|
||||||
|
getCategoryChildren(categories, deleteNode.id).length > 0 &&
|
||||||
|
' Alle Unterkategorien und darin enthaltenen Artikelzuweisungen werden ebenfalls entfernt.'}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => this.setState({ deleteDialogOpen: false })}
|
||||||
|
color="inherit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleConfirmDelete}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export { default as CategoryNameDialog } from './CategoryNameDialog';
|
|||||||
export { default as CategoryBreadcrumbs } from './CategoryBreadcrumbs';
|
export { default as CategoryBreadcrumbs } from './CategoryBreadcrumbs';
|
||||||
export { default as NewCategoryTile } from './NewCategoryTile';
|
export { default as NewCategoryTile } from './NewCategoryTile';
|
||||||
export { default as EditCategoryDialog } from './EditCategoryDialog';
|
export { default as EditCategoryDialog } from './EditCategoryDialog';
|
||||||
|
export { default as CategoryTree } from './CategoryTree';
|
||||||
|
export { default as CategoryPickerDialog } from '../article/CategoryPickerDialog';
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import IconButton from '@mui/material/IconButton';
|
|||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||||
@@ -20,7 +17,7 @@ import { tileBaseSx } from 'dashgui/tiles';
|
|||||||
* Icons are pinned to exact thirds (left / center / right) so they stay
|
* Icons are pinned to exact thirds (left / center / right) so they stay
|
||||||
* evenly spread regardless of icon size or button padding.
|
* evenly spread regardless of icon size or button padding.
|
||||||
*/
|
*/
|
||||||
function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick }) {
|
||||||
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
|
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
|
||||||
const isPhotoMode = settingsTab === -2;
|
const isPhotoMode = settingsTab === -2;
|
||||||
|
|
||||||
@@ -76,14 +73,16 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
|||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="droppable-handle"
|
aria-label="Kategorie verschieben"
|
||||||
aria-label="In Unterkategorie ablegen"
|
|
||||||
disableRipple
|
disableRipple
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPlacementClick?.();
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
...handleSx,
|
...handleSx,
|
||||||
right: 0,
|
right: 0,
|
||||||
cursor: 'grab',
|
cursor: 'pointer',
|
||||||
'&:active': { cursor: 'grabbing' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AccountTreeIcon sx={{ fontSize: 36 }} />
|
<AccountTreeIcon sx={{ fontSize: 36 }} />
|
||||||
@@ -156,83 +155,6 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom row (1/3): split into two sub-rows (each 1/6 of tile).
|
|
||||||
* Upper: click zone → navigate into category.
|
|
||||||
* Lower: gray band — left chevron | delete | right chevron.
|
|
||||||
*/
|
|
||||||
function BottomRow({ onOpen, editBand }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '33.33%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Upper half: click-through to open category */}
|
|
||||||
<Box
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onOpen}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onOpen?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { bgcolor: 'action.hover' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Lower half: reorder & delete */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 0.5,
|
|
||||||
bgcolor: 'grey.400',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
className="sortable-handle-btn"
|
|
||||||
size="small"
|
|
||||||
aria-label="Nach links verschieben"
|
|
||||||
disabled={!editBand.canLeft}
|
|
||||||
onClick={editBand.onMoveLeft}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
className="sortable-handle-btn"
|
|
||||||
size="small"
|
|
||||||
aria-label="Löschen"
|
|
||||||
onClick={editBand.onDelete}
|
|
||||||
>
|
|
||||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
className="sortable-handle-btn"
|
|
||||||
size="small"
|
|
||||||
aria-label="Nach rechts verschieben"
|
|
||||||
disabled={!editBand.canRight}
|
|
||||||
onClick={editBand.onMoveRight}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
|
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
|
||||||
@@ -373,13 +295,13 @@ export class CategoryTile extends Component {
|
|||||||
fullHash,
|
fullHash,
|
||||||
onClick,
|
onClick,
|
||||||
nameInput,
|
nameInput,
|
||||||
editBand,
|
|
||||||
onOpen,
|
|
||||||
terminals,
|
terminals,
|
||||||
categoryPosterminalIds,
|
categoryPosterminalIds,
|
||||||
onTerminalToggle,
|
onTerminalToggle,
|
||||||
onPhotoUpload,
|
onPhotoUpload,
|
||||||
onPhotoDelete,
|
onPhotoDelete,
|
||||||
|
onPlacementClick,
|
||||||
|
onOpen,
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { settingsTab } = this.state;
|
const { settingsTab } = this.state;
|
||||||
@@ -396,6 +318,7 @@ export class CategoryTile extends Component {
|
|||||||
onSettingsClick={this.handleSettingsToggle}
|
onSettingsClick={this.handleSettingsToggle}
|
||||||
settingsTab={settingsTab}
|
settingsTab={settingsTab}
|
||||||
terminalCount={terminals?.length || 0}
|
terminalCount={terminals?.length || 0}
|
||||||
|
onPlacementClick={onPlacementClick}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
onClick={this.handleMiddleBandClick}
|
onClick={this.handleMiddleBandClick}
|
||||||
@@ -417,7 +340,30 @@ export class CategoryTile extends Component {
|
|||||||
fullHash={fullHash}
|
fullHash={fullHash}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<BottomRow onOpen={onOpen} editBand={editBand} />
|
{/* Lower third: clickable in edit mode to open/navigate into category */}
|
||||||
|
<Box
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Kategorie öffnen"
|
||||||
|
onClick={onOpen || onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
(onOpen || onClick)?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '33.33%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
this.renderLabel()
|
this.renderLabel()
|
||||||
|
|||||||
@@ -28,32 +28,3 @@ html {
|
|||||||
cursor: grabbing !important;
|
cursor: grabbing !important;
|
||||||
user-select: none !important;
|
user-select: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shopify Droppable: aktuell anvisierte Ziel-Kachel hervorheben */
|
|
||||||
.tree-drop-target {
|
|
||||||
outline: 3px solid #1976d2;
|
|
||||||
outline-offset: -3px;
|
|
||||||
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Während des Tree-Draggings keine Hover-/Textselektions-Effekte auf den
|
|
||||||
Ziel-Kacheln: interaktive Kinder (Buttons, Inputs) vom Maus-Event abkoppeln,
|
|
||||||
damit keine :hover ausgelöst wird und der Cursor durchgängig 'grabbing' bleibt. */
|
|
||||||
body.tree-dragging {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.tree-dragging,
|
|
||||||
body.tree-dragging * {
|
|
||||||
cursor: grabbing !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.tree-dragging button,
|
|
||||||
body.tree-dragging input,
|
|
||||||
body.tree-dragging textarea,
|
|
||||||
body.tree-dragging .MuiButtonBase-root,
|
|
||||||
body.tree-dragging .MuiInputBase-root {
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { Droppable } from '@shopify/draggable';
|
|
||||||
import { notifyError } from '../../utils/notify';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates @shopify/draggable Droppable logic for moving a category
|
|
||||||
* into another category (tree drag-and-drop).
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* this.droppable = new CategoryDroppable(containerEl, { onMove });
|
|
||||||
* this.droppable.activate();
|
|
||||||
* this.droppable.destroy();
|
|
||||||
*/
|
|
||||||
export default class CategoryDroppable {
|
|
||||||
#instance = null;
|
|
||||||
#container = null;
|
|
||||||
#onMove = null;
|
|
||||||
#pendingDropId = null;
|
|
||||||
#dropTargetEl = null;
|
|
||||||
|
|
||||||
constructor(container, { onMove }) {
|
|
||||||
this.#container = container;
|
|
||||||
this.#onMove = onMove;
|
|
||||||
}
|
|
||||||
|
|
||||||
activate() {
|
|
||||||
const el = this.#container;
|
|
||||||
if (!el || this.#instance) return;
|
|
||||||
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
this.#instance = new Droppable(el, {
|
|
||||||
draggable: '.sortable-item',
|
|
||||||
dropzone: '.sortable-item',
|
|
||||||
handle: '.droppable-handle',
|
|
||||||
delay: 0,
|
|
||||||
mirror: { constrainDimensions: true },
|
|
||||||
classes: { 'droppable:active': '', 'droppable:occupied': '' },
|
|
||||||
});
|
|
||||||
this.#instance.on('drag:start', this.#handleTreeDragStart);
|
|
||||||
this.#instance.on('drag:over', this.#handleDropOver);
|
|
||||||
this.#instance.on('drag:out', this.#handleDropOut);
|
|
||||||
this.#instance.on('droppable:dropped', this.#handleDropped);
|
|
||||||
this.#instance.on('droppable:returned', this.#handleDroppedReturned);
|
|
||||||
this.#instance.on('drag:stop', this.#handleDragStop);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.#instance) {
|
|
||||||
this.#instance.destroy();
|
|
||||||
this.#instance = null;
|
|
||||||
}
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
this.#clearDropTarget();
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- private ---
|
|
||||||
|
|
||||||
#handleTreeDragStart = () => {
|
|
||||||
document.body.classList.add('tree-dragging');
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleDropped = (event) => {
|
|
||||||
event.cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleDropOver = (event) => {
|
|
||||||
const tile =
|
|
||||||
event?.over && typeof event.over.closest === 'function'
|
|
||||||
? event.over.closest('.sortable-item')
|
|
||||||
: null;
|
|
||||||
if (!tile) return;
|
|
||||||
const sourceId = Number(event?.source?.dataset?.id);
|
|
||||||
const targetId = Number(tile.dataset.id);
|
|
||||||
if (sourceId && targetId === sourceId) {
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#clearDropTarget();
|
|
||||||
this.#pendingDropId = targetId;
|
|
||||||
this.#dropTargetEl = tile;
|
|
||||||
tile.classList.add('tree-drop-target');
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleDropOut = () => {
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
this.#clearDropTarget();
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleDroppedReturned = (event) => {
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
this.#clearDropTarget();
|
|
||||||
event.cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleDragStop = (event) => {
|
|
||||||
document.body.classList.remove('tree-dragging');
|
|
||||||
const categoryId = Number(event?.source?.dataset?.id);
|
|
||||||
const targetId = this.#pendingDropId;
|
|
||||||
this.#pendingDropId = null;
|
|
||||||
this.#clearDropTarget();
|
|
||||||
if (!categoryId || !targetId || categoryId === targetId) return;
|
|
||||||
void this.#onMove({ categoryId, parentId: targetId })
|
|
||||||
.unwrap()
|
|
||||||
.catch(notifyError);
|
|
||||||
};
|
|
||||||
|
|
||||||
#clearDropTarget = () => {
|
|
||||||
if (this.#dropTargetEl) {
|
|
||||||
this.#dropTargetEl.classList.remove('tree-drop-target');
|
|
||||||
this.#dropTargetEl = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import CategorySortable from './CategorySortable';
|
import CategorySortable from './CategorySortable';
|
||||||
import CategoryDroppable from './CategoryDroppable';
|
import CategoryPickerDialog from '../../components/article/CategoryPickerDialog';
|
||||||
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
||||||
import { NewArticleTile } from '../../components/article';
|
import { NewArticleTile } from '../../components/article';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -24,37 +24,26 @@ import {
|
|||||||
resolveCategoryIdFromSlugPath,
|
resolveCategoryIdFromSlugPath,
|
||||||
ROOT_CATEGORY_NAME,
|
ROOT_CATEGORY_NAME,
|
||||||
} from './categoryRoute';
|
} from './categoryRoute';
|
||||||
import categoryAutomat from '../../automats/categoryAutomat';
|
import categoryAutomat, { getOrCreateCategoryAutomat } from '../../automats/categoryAutomat';
|
||||||
import posterminalAutomat from '../../automats/posterminalAutomat';
|
import posterminalAutomat from '../../automats/posterminalAutomat';
|
||||||
import companyAutomat from '../../automats/companyAutomat';
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku } from '../../automats';
|
||||||
import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils';
|
import {
|
||||||
|
categoryDisplayName,
|
||||||
|
getCategoryBreadcrumbs,
|
||||||
|
getCategoryChildren,
|
||||||
|
getCategoryDescendantIds,
|
||||||
|
} from '../../utils/categoryUtils';
|
||||||
|
|
||||||
// ─── Helper selectors for this page ────────────────────────────────────────────
|
// ─── Helper selectors for this page ────────────────────────────────────────────
|
||||||
function selectFromCategory(s) {
|
function selectFromCategory(s) {
|
||||||
const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s;
|
const { categories, rootCategoryId, renameMode, treeStatus, 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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
treeStatus,
|
|
||||||
articlesStatus,
|
|
||||||
error,
|
|
||||||
currentCategoryId,
|
|
||||||
breadcrumbs,
|
|
||||||
childCategories,
|
|
||||||
articles,
|
|
||||||
articlesTotal,
|
|
||||||
canGoBack,
|
|
||||||
renameMode,
|
renameMode,
|
||||||
|
treeStatus,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,15 +60,36 @@ const gridSx = {
|
|||||||
class ArtikelKategorienPage extends PureComponent {
|
class ArtikelKategorienPage extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(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 = {
|
this.state = {
|
||||||
drafts: {},
|
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),
|
...selectTerminals(posterminalAutomat.state),
|
||||||
companyId: companyAutomat.state.current?.id ?? null,
|
companyId: companyAutomat.state.current?.id ?? null,
|
||||||
|
moveCategory: null,
|
||||||
};
|
};
|
||||||
this.gridRef = createRef();
|
this.gridRef = createRef();
|
||||||
this.sortableModule = null;
|
this.sortableModule = null;
|
||||||
this.droppableModule = null;
|
|
||||||
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
||||||
this.handleDraftChange = this.handleDraftChange.bind(this);
|
this.handleDraftChange = this.handleDraftChange.bind(this);
|
||||||
}
|
}
|
||||||
@@ -94,34 +104,29 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
this.ensureTreeLoaded();
|
this.ensureTreeLoaded();
|
||||||
this.ensurePosterminalsLoaded();
|
this.ensurePosterminalsLoaded();
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
this.ensureArticlesLoaded();
|
this.subscribeRouteAutomat();
|
||||||
this.syncSortable();
|
this.syncSortable();
|
||||||
this.syncDroppable();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
// Note: PureComponent — comparisons use this.state not this.props
|
|
||||||
if (this.state.companyId !== prevState.companyId) {
|
if (this.state.companyId !== prevState.companyId) {
|
||||||
this.ensureTreeLoaded();
|
this.ensureTreeLoaded();
|
||||||
}
|
}
|
||||||
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
|
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
this.ensureArticlesLoaded();
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.state.treeStatus === 'succeeded' &&
|
this.props.categorySlugPath !== prevProps.categorySlugPath ||
|
||||||
this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
|
this.state.categories !== prevState.categories ||
|
||||||
|
this.state.rootCategoryId !== prevState.rootCategoryId
|
||||||
) {
|
) {
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
}
|
}
|
||||||
this._prevSlugPath = this.props.categorySlugPath;
|
if (
|
||||||
|
this.state.renameMode !== prevState.renameMode ||
|
||||||
if (this.state.currentCategoryId !== prevState.currentCategoryId) {
|
(this.state.renameMode && this.state.resolvedCategoryId !== prevState.resolvedCategoryId)
|
||||||
this.ensureArticlesLoaded();
|
) {
|
||||||
}
|
|
||||||
if (this.state.renameMode !== prevState.renameMode) {
|
|
||||||
this.syncSortable();
|
this.syncSortable();
|
||||||
this.syncDroppable();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +134,21 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
this._unsubCat?.();
|
this._unsubCat?.();
|
||||||
this._unsubTerm?.();
|
this._unsubTerm?.();
|
||||||
this._unsubCompany?.();
|
this._unsubCompany?.();
|
||||||
|
this._unsubRoute?.();
|
||||||
this.sortableModule?.destroy();
|
this.sortableModule?.destroy();
|
||||||
this.droppableModule?.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 = () => {
|
#ensureModules = () => {
|
||||||
@@ -139,13 +157,10 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
if (!this.sortableModule) {
|
if (!this.sortableModule) {
|
||||||
this.sortableModule = new CategorySortable(el, {
|
this.sortableModule = new CategorySortable(el, {
|
||||||
onReorder: ({ categoryIds }) =>
|
onReorder: ({ categoryIds }) =>
|
||||||
categoryAutomat.actions.reorderCategories({ categoryIds }),
|
categoryAutomat.actions.reorderCategories({
|
||||||
});
|
parentId: this.state.resolvedCategoryId,
|
||||||
}
|
categoryIds,
|
||||||
if (!this.droppableModule) {
|
}),
|
||||||
this.droppableModule = new CategoryDroppable(el, {
|
|
||||||
onMove: ({ categoryId, parentId }) =>
|
|
||||||
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -160,20 +175,6 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncDroppable = () => {
|
|
||||||
this.#ensureModules();
|
|
||||||
if (this.state.renameMode) {
|
|
||||||
this.droppableModule?.destroy();
|
|
||||||
this.droppableModule?.activate();
|
|
||||||
} else {
|
|
||||||
this.droppableModule?.destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveCategory = (category, direction) => {
|
|
||||||
this.sortableModule?.handleMoveCategory(category.id, direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
ensureTreeLoaded = () => {
|
ensureTreeLoaded = () => {
|
||||||
const { companyId, treeStatus } = this.state;
|
const { companyId, treeStatus } = this.state;
|
||||||
if (!companyId || treeStatus !== 'idle') return;
|
if (!companyId || treeStatus !== 'idle') return;
|
||||||
@@ -186,19 +187,12 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
void posterminalAutomat.actions.load();
|
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 = () => {
|
syncCategoryFromUrl = () => {
|
||||||
const {
|
const {
|
||||||
categories,
|
categories,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
currentCategoryId,
|
|
||||||
treeStatus,
|
treeStatus,
|
||||||
|
resolvedCategoryId,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
const { categorySlugPath, navigate } = this.props;
|
const { categorySlugPath, navigate } = this.props;
|
||||||
|
|
||||||
@@ -222,8 +216,23 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
|
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedId !== currentCategoryId) {
|
const cleanSlug = (categorySlugPath ?? '').replace(/^\/+|\/+$/g, '');
|
||||||
categoryAutomat.actions.navigateTo(resolvedId);
|
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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,10 +261,28 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDeleteCategory = async (category) => {
|
handleOpenMoveCategory = (category) => {
|
||||||
|
this.setState({ moveCategory: category });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseMoveCategory = () => {
|
||||||
|
this.setState({ moveCategory: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleConfirmMoveCategory = async (targetParentId) => {
|
||||||
|
const { moveCategory } = this.state;
|
||||||
|
if (!moveCategory || targetParentId == null) return;
|
||||||
|
if (String(targetParentId) === String(moveCategory.parentId)) {
|
||||||
|
this.setState({ moveCategory: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await categoryAutomat.actions.deleteCategory(category.id);
|
await categoryAutomat.actions.moveCategory({
|
||||||
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
|
categoryId: moveCategory.id,
|
||||||
|
parentId: targetParentId,
|
||||||
|
});
|
||||||
|
enqueueSnackbar(`Kategorie „${moveCategory.name}“ verschoben.`, { variant: 'success' });
|
||||||
|
this.setState({ moveCategory: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
}
|
}
|
||||||
@@ -266,14 +293,20 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleRetryArticles = () => {
|
handleRetryArticles = () => {
|
||||||
const { currentCategoryId } = this.state;
|
if (this.routeAutomat) {
|
||||||
if (currentCategoryId) {
|
if (typeof this.routeAutomat.retry === 'function') {
|
||||||
void categoryAutomat.actions.loadArticles(currentCategoryId);
|
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 = () => {
|
handleNavigateBack = () => {
|
||||||
const { breadcrumbs } = this.state;
|
const { categories, resolvedCategoryId, rootCategoryId } = this.state;
|
||||||
|
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
|
||||||
if (breadcrumbs.length < 2) return;
|
if (breadcrumbs.length < 2) return;
|
||||||
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
|
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
|
||||||
};
|
};
|
||||||
@@ -286,18 +319,19 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
companyId,
|
companyId,
|
||||||
|
categories,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
treeStatus,
|
treeStatus,
|
||||||
articlesStatus,
|
|
||||||
error,
|
error,
|
||||||
canGoBack,
|
resolvedCategoryId,
|
||||||
breadcrumbs,
|
|
||||||
currentCategoryId,
|
|
||||||
childCategories,
|
|
||||||
articles,
|
articles,
|
||||||
|
loaded,
|
||||||
|
loading,
|
||||||
|
routeError,
|
||||||
renameMode,
|
renameMode,
|
||||||
terminals,
|
terminals,
|
||||||
drafts,
|
drafts,
|
||||||
|
moveCategory,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
@@ -333,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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -342,9 +385,9 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
onNavigate={this.navigateToCategoryById}
|
onNavigate={this.navigateToCategoryById}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && articlesStatus === 'failed' && (
|
{routeError && (
|
||||||
<BackendError
|
<BackendError
|
||||||
error={error}
|
error={routeError}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
onRetry={this.handleRetryArticles}
|
onRetry={this.handleRetryArticles}
|
||||||
/>
|
/>
|
||||||
@@ -354,12 +397,12 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
{canGoBack && (
|
{canGoBack && (
|
||||||
<BackTile
|
<BackTile
|
||||||
onClick={this.handleNavigateBack}
|
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"
|
className="sortable-item not-sortable"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{childCategories.map((item, index) => {
|
{childCategories.map((item) => {
|
||||||
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
||||||
const draftName =
|
const draftName =
|
||||||
drafts[item.id] !== undefined
|
drafts[item.id] !== undefined
|
||||||
@@ -387,6 +430,7 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
})
|
})
|
||||||
.catch(notifyError);
|
.catch(notifyError);
|
||||||
},
|
},
|
||||||
|
onPlacementClick: () => this.handleOpenMoveCategory(item),
|
||||||
onOpen: () => this.navigateToCategoryById(item.id),
|
onOpen: () => this.navigateToCategoryById(item.id),
|
||||||
onPhotoUpload: () => {
|
onPhotoUpload: () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -439,13 +483,6 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
editBand: {
|
|
||||||
onMoveLeft: () => this.handleMoveCategory(item, -1),
|
|
||||||
onMoveRight: () => this.handleMoveCategory(item, 1),
|
|
||||||
onDelete: () => this.handleDeleteCategory(item),
|
|
||||||
canLeft: index > 0,
|
|
||||||
canRight: index < childCategories.length - 1,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
label,
|
label,
|
||||||
@@ -462,21 +499,23 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!renameMode && articles.map((item) => (
|
{!renameMode && articles.map((item, index) => (
|
||||||
<ArticleTile
|
<ArticleTile
|
||||||
key={item.id}
|
key={item.id}
|
||||||
label={item.name != null ? item.name.trim() : undefined}
|
label={item.name != null ? item.name.trim() : undefined}
|
||||||
article={item}
|
article={item}
|
||||||
disabled={!articleSku(item)}
|
disabled={!articleSku(item)}
|
||||||
onClick={() => this.handleArticleClick(item)}
|
onClick={() => this.handleArticleClick(item)}
|
||||||
|
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{renameMode && <NewCategoryTile parentId={currentCategoryId} />}
|
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
|
||||||
{renameMode && articlesStatus === 'succeeded' && (
|
{renameMode && loaded && (
|
||||||
<NewArticleTile
|
<NewArticleTile
|
||||||
parentId={currentCategoryId}
|
parentId={resolvedCategoryId}
|
||||||
onOpenArticle={this.handleArticleClick}
|
onOpenArticle={this.handleArticleClick}
|
||||||
|
sx={{ gridColumnStart: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -486,9 +525,25 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{moveCategory && (
|
||||||
|
<CategoryPickerDialog
|
||||||
|
open={Boolean(moveCategory)}
|
||||||
|
onClose={this.handleCloseMoveCategory}
|
||||||
|
categories={categories}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
title={`„${categoryDisplayName(moveCategory.name, moveCategory.id, rootCategoryId)}“ verschieben nach…`}
|
||||||
|
confirmLabel="Hierher verschieben"
|
||||||
|
mode="single"
|
||||||
|
selectedId={moveCategory.parentId}
|
||||||
|
excludeIds={getCategoryDescendantIds(categories, moveCategory.id)}
|
||||||
|
onSelectCategory={this.handleConfirmMoveCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withCategoryRoute(ArtikelKategorienPage);
|
export default withCategoryRoute(ArtikelKategorienPage);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export function parseCategoryTreeResponse(data, companyRootCategoryId) {
|
|||||||
return {
|
return {
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
categories,
|
categories,
|
||||||
rootArticles: Array.isArray(data.articles) ? data.articles : [],
|
|
||||||
languageId: data.languageId,
|
languageId: data.languageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,3 +108,27 @@ export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCatego
|
|||||||
export function normalizeCategoryMap(data, companyRootCategoryId) {
|
export function normalizeCategoryMap(data, companyRootCategoryId) {
|
||||||
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
|
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array containing the category id and all of its recursive descendant ids.
|
||||||
|
* Useful for preventing cyclic moves.
|
||||||
|
* @param {object} categories - id -> node map
|
||||||
|
* @param {number|string} categoryId - Target category id
|
||||||
|
* @returns {number[]} Array of category ids
|
||||||
|
*/
|
||||||
|
export function getCategoryDescendantIds(categories, categoryId) {
|
||||||
|
const result = [];
|
||||||
|
if (!categories || categoryId == null) return result;
|
||||||
|
const numId = Number(categoryId);
|
||||||
|
result.push(numId);
|
||||||
|
const queue = [numId];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift();
|
||||||
|
const children = getCategoryChildren(categories, currentId);
|
||||||
|
for (const child of children) {
|
||||||
|
result.push(Number(child.id));
|
||||||
|
queue.push(Number(child.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user