This commit is contained in:
seb
2026-09-22 07:20:31 +02:00
parent 536e7b1e2a
commit 5e13b8f9cd
32 changed files with 2485 additions and 1908 deletions

466
NGINX.md Normal file
View File

@@ -0,0 +1,466 @@
# NGINX Configuration Guide for Dashfront & Dashback
This document provides a production-tested, complete NGINX reverse-proxy and static hosting guide for **Dashfront** (Vite + React 19 PWA) and **Dashback** (Express REST + SSE API).
---
## Table of Contents
1. [Architecture & Request Flow](#1-architecture--request-flow)
2. [Key Technical Requirements](#2-key-technical-requirements)
3. [Production Configuration (`dashfront.conf`)](#3-production-configuration-dashfrontconf)
4. [Detailed Breakdown of Critical Directives](#4-detailed-breakdown-of-critical-directives)
- [PWA & Cache Invalidation Strategy](#pwa--cache-invalidation-strategy)
- [SPA Routing (`try_files`)](#spa-routing-try_files)
- [Server-Sent Events (SSE) Buffering & Timeouts](#server-sent-events-sse-buffering--timeouts)
- [File Uploads & Request Body Limits](#file-uploads--request-body-limits)
- [WebAssembly (WASM) MIME Types](#webassembly-wasm-mime-types)
- [Camera & Hardware Permissions](#camera--hardware-permissions)
5. [Alternative: Remote Vite Dev Server Proxy](#5-alternative-remote-vite-dev-server-proxy)
6. [Deployment & Operations Runbook](#6-deployment--operations-runbook)
7. [Troubleshooting Matrix](#7-troubleshooting-matrix)
---
## 1. Architecture & Request Flow
In production, NGINX acts as both the high-performance static web server for Dashfront assets and the secure reverse proxy for the Dashback Express service.
```
┌──────────────────────────────────────────────┐
│ CLIENT │
│ (Desktop Browser, Tablet, Mobile PWA) │
└──────────────────────┬───────────────────────┘
│ HTTPS (:443)
▼
┌──────────────────────────────────────────────┐
│ NGINX │
│ TLS Termination, Gzip, Caching, Auth │
└──────────────┬────────────────┬──────────────┘
│ │
/assets/*, /index.html │ │ /api/*
/sw.js, static files │ │ (REST & SSE)
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Static Build Dir │ │ Dashback │
│ `dashfront/dist` │ │ Express API │
│ │ │ (localhost:4208) │
└───────────────────┘ └───────────────────┘
```
| Path | Handled By | Cache Policy | Notes |
|---|---|---|---|
| `/index.html` & `/` | NGINX static file | `no-cache, no-store, must-revalidate` | Always fresh so users get new script hashes |
| `/sw.js`, `/registerSW.js` | NGINX static file | `no-cache, no-store, must-revalidate` | **Must never cache**; avoids bricking PWA updates |
| `/workbox-*.js` | NGINX static file | `no-cache, no-store, must-revalidate` | Workbox runtime loader |
| `/manifest.webmanifest` | NGINX static file | `public, max-age=3600` | PWA manifest definition |
| `/assets/*` | NGINX static file | `public, max-age=31536000, immutable` | Content-hashed JS, CSS, WASM, and workers |
| `/api/events` | Dashback (`:4208`) | `no-cache, no-buffering` | Server-Sent Events (SSE) live push notifications |
| `/api/*` | Dashback (`:4208`) | Dynamic / Backend controlled | REST endpoints, auth cookies, image & PDF uploads |
| All other paths | NGINX `try_files` | Fallback to `/index.html` | React Router HTML5 History API deep linking |
---
## 2. Key Technical Requirements
1. **PWA Lifecycles**: A Progressive Web App checks for updates by fetching `sw.js`. If NGINX caches `sw.js` or `index.html`, client devices can remain stuck on obsolete bundles indefinitely.
2. **SSE Streaming**: Real-time push updates at `/api/events` require NGINX proxy buffering and proxy caching to be turned **OFF**, with a 24-hour read timeout to prevent arbitrary 60-second connection drops.
3. **Payload Limits**: Inbound delivery invoices (PDF receipts scanned client-side) and high-resolution article photos (AVIF) require a request body limit of at least `50m`–`100m` (matching Dashback's `BODY_LIMIT`).
4. **Cookie Authentication**: Dashback issues HTTP-only `token` session cookies. NGINX must pass the original `Host`, `X-Real-IP`, and `X-Forwarded-Proto https` headers so Express sets and retains cookies correctly.
5. **Camera Access (HTTPS)**: Mobile barcode scanning requires a secure origin (HTTPS) and appropriate `Permissions-Policy` response headers.
---
## 3. Production Configuration (`dashfront.conf`)
Place this configuration file at `/etc/nginx/sites-available/dashfront` (or adapt it to your existing reverse proxy structure):
```nginx
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
# Map Connection header for WebSocket upgrades (if proxying dev server or WebSockets)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dash.example.com; # <-- Replace with your domain or server IP
# Let's Encrypt / ACME challenge directory
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
# NGINX >= 1.25.1 uses 'http2 on;'. For older NGINX, use: listen 443 ssl http2;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dash.example.com; # <-- Replace with your domain or server IP
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Static File Root ---
# Point directly to the dist/ directory created by `npm run build`
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads ---
# Supports large delivery PDF uploads and client-encoded AVIF photos
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Allow camera access for the built-in barcode scanner
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
# Clear Connection header to maintain persistent HTTP/1.1 stream
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering and caching completely for immediate push
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Prevent premature disconnections; keep SSE open for 24 hours
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Retain cookies across domain/path
proxy_cookie_path / /;
# Streaming uploads (PDF receipts, AVIF photos)
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
# Service worker scripts MUST NEVER be cached by HTTP cache
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
# PWA web manifest and browser icons
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
# Vite content-hashed assets: JS, CSS, WASM, Web Workers
# (Example: /assets/index-CutpK3vd.js, /assets/avif_enc-Co4TcJko.wasm)
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Ensure WebAssembly MIME type is correctly set
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
# index.html must never be cached so new release deployments take effect immediately
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SPA routing: Serve existing static file or directory; otherwise fallback to /index.html
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## 4. Detailed Breakdown of Critical Directives
### PWA & Cache Invalidation Strategy
PWA update lifecycles depend on byte-for-byte comparisons of `sw.js` when the user visits the page:
1. **`sw.js` and `registerSW.js`**:
```nginx
add_header Cache-Control "no-cache, no-store, must-revalidate";
```
If NGINX serves `sw.js` with a 30-day cache header, browser service worker checks will read from disk cache and never detect new application releases.
2. **`index.html`**:
Must also have `no-cache, no-store, must-revalidate`. When a new build is run, Vite produces chunks with new hashes (e.g., `index-AbCd12.js`). If `index.html` is cached, users continue loading old references that may no longer exist on disk.
3. **`/assets/`**:
Because Vite / Rolldown generates content-addressed filenames (hashes change whenever contents change), these files are completely immutable and safe to cache for 1 year (`max-age=31536000, immutable`).
### SPA Routing (`try_files`)
Dashfront uses React Router with HTML5 History API pushState:
- Direct visits to URLs like `/artikel/10001`, `/kategorien/12`, `/lieferung/neu`, or `/kunde` do not correspond to files on disk.
- The directive:
```nginx
try_files $uri $uri/ /index.html;
```
instructs NGINX to first check if a matching static file exists (e.g. `/favicon.svg` or `/assets/...`). If not found, it internally rewrites the request to `/index.html` without returning an HTTP 404, allowing React Router to resolve the route in-browser.
### Server-Sent Events (SSE) Buffering & Timeouts
The backend provides real-time model invalidation via `GET /api/events`. By default, NGINX buffers HTTP responses. If buffering is enabled:
1. Events sent by Dashback stay in NGINX's internal memory buffer until the buffer fills up (often several kilobytes), causing clients to miss instant UI updates.
2. NGINX will close idle proxy connections after `proxy_read_timeout` (default 60s).
The dedicated `/api/events` block fixes both issues:
```nginx
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_set_header Connection "";
proxy_read_timeout 86400s;
```
### File Uploads & Request Body Limits
In Dashfront:
- Inbound deliveries support multi-page PDF invoices and OCR processing (`pdfconvert-wasm`).
- Article management supports client-side AVIF image generation.
- The default NGINX limit is `1m` (`client_max_body_size 1m;`), which causes HTTP 413 (Payload Too Large) errors on invoice uploads.
- Setting:
```nginx
client_max_body_size 100m;
proxy_request_buffering off;
```
allows uploads up to 100 megabytes and streams the body directly to Express without waiting for NGINX to buffer the entire file on disk.
### WebAssembly (WASM) MIME Types
Dashfront uses WebAssembly for:
- `@jsquash/avif` (image encoding)
- `@jsquash/resize` (squoosh resizing)
- `pdfconvert-wasm` (PDF page extraction and rasterization)
Modern browsers require the HTTP header `Content-Type: application/wasm` for streaming compilation (`WebAssembly.instantiateStreaming`). If missing, NGINX may default to `application/octet-stream`. The WASM block guarantees the correct MIME header:
```nginx
location ~* \.wasm$ {
default_type application/wasm;
}
```
### Camera & Hardware Permissions
The article lookup and delivery receipt features allow scanning barcodes using device cameras. Web browsers enforce two strict rules for camera access:
1. **Secure Origin**: The page must be served over HTTPS or `localhost`. Plain HTTP over local IP addresses (e.g., `http://192.168.1.100/`) will automatically disable `navigator.mediaDevices`.
2. **Permissions Policy**: The header `Permissions-Policy "camera=(self)"` ensures the browser allows Dashfront to access connected webcams or smartphone cameras.
---
## 5. Alternative: Remote Vite Dev Server Proxy
If you want to access the live **Vite development server** (running on `localhost:4209` with HMR) through NGINX from a smartphone, tablet, or external domain:
```nginx
# Map connection upgrade for Vite HMR WebSockets
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name dev-dash.example.com;
ssl_certificate /etc/letsencrypt/live/dev-dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev-dash.example.com/privkey.pem;
client_max_body_size 100m;
# 1. API Calls -> Dashback (Port 4208)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 86400s;
}
# 2. Vite Dev Server & HMR -> Vite (Port 4209)
location / {
proxy_pass http://127.0.0.1:4209;
proxy_http_version 1.1;
# Crucial for Vite Hot Module Replacement (HMR) WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
}
}
```
> [!NOTE]
> When proxying to Vite dev server, ensure `server.allowedHosts = true` or add your domain to `allowedHosts` in [vite.config.js](file:///home/seb/src/dashfront/vite.config.js).
---
## 6. Deployment & Operations Runbook
### Step 1: Build Dashfront for Production
On the development machine or build server:
```bash
cd /home/seb/src/dashfront
npm run build
```
This compiles optimized bundles and service worker files into `dashfront/dist`.
### Step 2: Install and Verify NGINX Configuration
```bash
# 1. Copy config file to sites-available
sudo cp deploy/nginx-dashfront.conf /etc/nginx/sites-available/dashfront
# 2. Enable site by creating a symlink
sudo ln -s /etc/nginx/sites-available/dashfront /etc/nginx/sites-enabled/
# 3. Test configuration syntax
sudo nginx -t
# 4. Reload NGINX without dropping active connections
sudo systemctl reload nginx
```
### Step 3: Obtain TLS Certificates with Certbot
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d dash.example.com
```
### Step 4: Ensure Filesystem Permissions
NGINX worker processes run as the `www-data` user by default. Ensure the directory path to `dist/` is readable:
```bash
# Allow www-data execution on parent path components
chmod 755 /home/seb /home/seb/src /home/seb/src/dashfront /home/seb/src/dashfront/dist
```
Or deploy build artifacts into a standard web directory:
```bash
sudo mkdir -p /var/www/dashfront
sudo rsync -av --delete /home/seb/src/dashfront/dist/ /var/www/dashfront/dist/
sudo chown -R www-data:www-data /var/www/dashfront
```
---
## 7. Troubleshooting Matrix
| Symptom | Cause | Solution |
|---|---|---|
| **HTTP 404 on page reload** on sub-routes (`/artikel`, `/kunde`) | Missing SPA fallback in NGINX | Ensure `try_files $uri $uri/ /index.html;` is present in `location /`. |
| **PWA does not update** after deployment | `sw.js` or `index.html` cached in browser | Verify `Cache-Control: no-cache, no-store, must-revalidate` on `sw.js`, `registerSW.js`, and `index.html`. |
| **HTTP 413 (Payload Too Large)** when uploading invoice PDF or image | Default NGINX upload limit is 1MB | Add `client_max_body_size 100m;` to the `server` or `location /api/` block. |
| **Live SSE events disconnect every 60s** | Default `proxy_read_timeout` timeout | Set `proxy_read_timeout 86400s;` and `proxy_buffering off;` on `/api/events`. |
| **Login loop / Cookie not sent** | Secure cookie rejected on plain HTTP, or missing proxy headers | Access via HTTPS; ensure `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` are set. |
| **Barcode camera scanner disabled** | Page accessed over insecure HTTP or missing permissions policy | Use HTTPS; check that `Permissions-Policy "camera=(self)"` header is present. |
| **WASM compilation error** (`incorrect response MIME type`) | NGINX serving `.wasm` as `application/octet-stream` | Add `default_type application/wasm;` for `\.wasm$` extensions. |
| **Vite HMR disconnects immediately** (Dev proxy) | Missing WebSocket upgrade headers | Ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection $connection_upgrade;` are configured. |

View File

@@ -24,6 +24,8 @@ Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
To test the full PWA precache (production build + preview, no HMR), use
`npm run dev:pwa` instead.
For production deployment with NGINX, see [NGINX.md](file:///home/seb/src/dashfront/NGINX.md).
Dashback must be running and your Google account must be authorized on the
backend.

168
deploy/nginx-dashfront.conf Normal file
View File

@@ -0,0 +1,168 @@
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dash.example.com; # <-- Replace with your domain or server IP
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dash.example.com; # <-- Replace with your domain or server IP
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Static File Root ---
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads (PDF receipts, AVIF photos) ---
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location / {
try_files $uri $uri/ /index.html;
}
}

628
package-lock.json generated
View File

@@ -14,16 +14,14 @@
"@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",
"react-automat": "^0.1.0",
"react-automat": "file:../automat",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.4",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
@@ -35,6 +33,25 @@
"vite-plugin-pwa": "^1.3.0"
}
},
"../automat": {
"name": "react-automat",
"version": "0.1.0",
"license": "0BSD",
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"vite": "^8.3.0"
},
"peerDependencies": {
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"../dashgui": {
"version": "0.1.0",
"devDependencies": {
@@ -57,23 +74,6 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
"integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"jsonpointer": "^5.0.1",
"leven": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"ajv": ">=8"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -2317,32 +2317,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
@@ -2744,9 +2718,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
"integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
"integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
"cpu": [
"arm"
],
@@ -2758,9 +2732,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
"integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
"integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
"cpu": [
"arm64"
],
@@ -2772,9 +2746,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
"integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
"integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
"cpu": [
"arm64"
],
@@ -2786,9 +2760,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
"integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
"integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
"cpu": [
"x64"
],
@@ -2800,9 +2774,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
"integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
"integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
"cpu": [
"arm64"
],
@@ -2814,9 +2788,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
"integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
"integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
"cpu": [
"x64"
],
@@ -2828,9 +2802,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
"integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
"integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
"cpu": [
"arm"
],
@@ -2845,9 +2819,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
"integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
"integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
"cpu": [
"arm"
],
@@ -2862,9 +2836,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
"integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
"integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
"cpu": [
"arm64"
],
@@ -2879,9 +2853,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
"integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
"integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
"cpu": [
"arm64"
],
@@ -2896,9 +2870,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
"integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
"integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
"cpu": [
"loong64"
],
@@ -2913,9 +2887,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
"integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
"integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
"cpu": [
"loong64"
],
@@ -2930,9 +2904,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
"integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
"integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
"cpu": [
"ppc64"
],
@@ -2947,9 +2921,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
"integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
"integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
"cpu": [
"ppc64"
],
@@ -2964,9 +2938,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
"integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
"integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
"cpu": [
"riscv64"
],
@@ -2981,9 +2955,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
"integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
"integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
"cpu": [
"riscv64"
],
@@ -2998,9 +2972,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
"integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
"integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
"cpu": [
"s390x"
],
@@ -3015,9 +2989,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
"integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
"integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
"cpu": [
"x64"
],
@@ -3032,9 +3006,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
"integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
"integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
"cpu": [
"x64"
],
@@ -3049,9 +3023,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
"integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
"integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
"cpu": [
"x64"
],
@@ -3063,9 +3037,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
"integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
"integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
"cpu": [
"arm64"
],
@@ -3077,9 +3051,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
"integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
"integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
"cpu": [
"arm64"
],
@@ -3091,9 +3065,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
"integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
"integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
"cpu": [
"ia32"
],
@@ -3105,9 +3079,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
"integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
"integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
"cpu": [
"x64"
],
@@ -3119,9 +3093,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
"integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
"integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
"cpu": [
"x64"
],
@@ -3138,18 +3112,6 @@
"integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@@ -3229,12 +3191,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -3287,16 +3243,15 @@
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
@@ -3991,9 +3946,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.433",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz",
"integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==",
"version": "1.5.434",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.434.tgz",
"integrity": "sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==",
"dev": true,
"license": "ISC"
},
@@ -4280,89 +4235,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/eslint/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
@@ -4551,17 +4423,19 @@
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
@@ -4997,16 +4871,6 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5570,10 +5434,9 @@
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -5930,16 +5793,18 @@
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=8"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
@@ -6178,32 +6043,33 @@
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=6"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=8"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
@@ -6481,18 +6347,8 @@
}
},
"node_modules/react-automat": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/react-automat/-/react-automat-0.1.0.tgz",
"integrity": "sha512-/lGKX2gXyBo52rRc6qC0ZGeThuD2C/LNjJWCChA/WC3JCJ4anyx7xCREfNmMOgRUBE0k4+LF0EbvzJwb4/RxfQ==",
"license": "0BSD",
"peerDependencies": {
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
"resolved": "../automat",
"link": true
},
"node_modules/react-dom": {
"version": "19.3.0",
@@ -6512,29 +6368,6 @@
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
@@ -6589,21 +6422,6 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6733,12 +6551,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/reselect": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6804,9 +6616,9 @@
}
},
"node_modules/rollup": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
"integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
"integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6821,31 +6633,31 @@
},
"optionalDependencies": {
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
"@rollup/rollup-android-arm-eabi": "4.63.3",
"@rollup/rollup-android-arm64": "4.63.3",
"@rollup/rollup-darwin-arm64": "4.63.3",
"@rollup/rollup-darwin-x64": "4.63.3",
"@rollup/rollup-freebsd-arm64": "4.63.3",
"@rollup/rollup-freebsd-x64": "4.63.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
"@rollup/rollup-linux-arm-musleabihf": "4.63.3",
"@rollup/rollup-linux-arm64-gnu": "4.63.3",
"@rollup/rollup-linux-arm64-musl": "4.63.3",
"@rollup/rollup-linux-loong64-gnu": "4.63.3",
"@rollup/rollup-linux-loong64-musl": "4.63.3",
"@rollup/rollup-linux-ppc64-gnu": "4.63.3",
"@rollup/rollup-linux-ppc64-musl": "4.63.3",
"@rollup/rollup-linux-riscv64-gnu": "4.63.3",
"@rollup/rollup-linux-riscv64-musl": "4.63.3",
"@rollup/rollup-linux-s390x-gnu": "4.63.3",
"@rollup/rollup-linux-x64-gnu": "4.63.3",
"@rollup/rollup-linux-x64-musl": "4.63.3",
"@rollup/rollup-openbsd-x64": "4.63.3",
"@rollup/rollup-openharmony-arm64": "4.63.3",
"@rollup/rollup-win32-arm64-msvc": "4.63.3",
"@rollup/rollup-win32-ia32-msvc": "4.63.3",
"@rollup/rollup-win32-x64-gnu": "4.63.3",
"@rollup/rollup-win32-x64-msvc": "4.63.3",
"@rollup/rollup-android-arm-eabi": "4.63.4",
"@rollup/rollup-android-arm64": "4.63.4",
"@rollup/rollup-darwin-arm64": "4.63.4",
"@rollup/rollup-darwin-x64": "4.63.4",
"@rollup/rollup-freebsd-arm64": "4.63.4",
"@rollup/rollup-freebsd-x64": "4.63.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
"@rollup/rollup-linux-arm-musleabihf": "4.63.4",
"@rollup/rollup-linux-arm64-gnu": "4.63.4",
"@rollup/rollup-linux-arm64-musl": "4.63.4",
"@rollup/rollup-linux-loong64-gnu": "4.63.4",
"@rollup/rollup-linux-loong64-musl": "4.63.4",
"@rollup/rollup-linux-ppc64-gnu": "4.63.4",
"@rollup/rollup-linux-ppc64-musl": "4.63.4",
"@rollup/rollup-linux-riscv64-gnu": "4.63.4",
"@rollup/rollup-linux-riscv64-musl": "4.63.4",
"@rollup/rollup-linux-s390x-gnu": "4.63.4",
"@rollup/rollup-linux-x64-gnu": "4.63.4",
"@rollup/rollup-linux-x64-musl": "4.63.4",
"@rollup/rollup-openbsd-x64": "4.63.4",
"@rollup/rollup-openharmony-arm64": "4.63.4",
"@rollup/rollup-win32-arm64-msvc": "4.63.4",
"@rollup/rollup-win32-ia32-msvc": "4.63.4",
"@rollup/rollup-win32-x64-gnu": "4.63.4",
"@rollup/rollup-win32-x64-msvc": "4.63.4",
"fsevents": "~2.3.2"
}
},
@@ -7641,15 +7453,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -7955,6 +7758,47 @@
"node": ">=20.0.0"
}
},
"node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
"integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"jsonpointer": "^5.0.1",
"leven": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"ajv": ">=8"
}
},
"node_modules/workbox-build/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/workbox-build/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-build/node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -8181,6 +8025,62 @@
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -20,16 +20,14 @@
"@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",
"react-automat": "^0.1.0",
"react-automat": "file:../automat",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.4",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"

View File

@@ -1,8 +1,14 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
import { ApiClient } from './ApiClient';
import artikelAutomat from '../automats/artikelAutomat';
import companyAutomat from '../automats/companyAutomat';
import brandAutomat from '../automats/brandAutomat';
import unitAutomat from '../automats/unitAutomat';
import authAutomat from '../automats/authAutomat';
import categoryAutomat from '../automats/categoryAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
@@ -172,16 +178,12 @@ export class StorageManager extends ApiClient {
* @returns {{ categories: object, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const treeData = this.categoryTree;
const companies = this.companies;
const session = this.authMe;
if (treeData == null || companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
const company = companies.find((c) => c.id === companyId);
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,
@@ -189,6 +191,15 @@ export class StorageManager extends ApiClient {
};
}
// New path: use categoryAutomat state directly
const catState = categoryAutomat.state;
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
return {
categories: catState.categories,
rootCategoryId: catState.rootCategoryId,
};
}
/**
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
@@ -375,13 +386,6 @@ export class StorageManager extends ApiClient {
#initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const articleIndexCache = this.#articleIndexCache;
const brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
@@ -391,6 +395,19 @@ export class StorageManager extends ApiClient {
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('GET /order', () => this.#loadOrders());
// articleIndex is auth-gated — create a deferred cache that only loads after auth
this.#articleIndexCache = this.#createDeferredCache(
'GET /articleIndex',
() => this.#loadArticleIndex(),
);
const articleIndexCache = this.#articleIndexCache;
// category tree: deferred, reloaded via categoryAutomat
const categoryTreeCache = this.#createDeferredCache(
'GET /category/tree',
() => this.#loadCategoryTree(),
);
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
@@ -400,29 +417,68 @@ export class StorageManager extends ApiClient {
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
/** Brand list from brandAutomat (may be empty until authed). */
brands: { get: () => brandAutomat.state.brands },
/** Promise that resolves when brands are first loaded. */
brandPromise: {
get: () => brandAutomat.state.status === 'succeeded'
? Promise.resolve(brandAutomat.state.brands)
: new Promise((resolve) => {
const unsub = brandAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
});
}),
},
/** Reload brands from API. */
updateBrand: { value: () => brandAutomat.actions.load() },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
/** Unit list from unitAutomat. */
units: { get: () => unitAutomat.state.units },
/** Promise that resolves when units are first loaded. */
unitPromise: {
get: () => unitAutomat.state.status === 'succeeded'
? Promise.resolve(unitAutomat.state.units)
: new Promise((resolve) => {
const unsub = unitAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
});
}),
},
/** Reload units from API. */
updateUnit: { value: () => unitAutomat.actions.load() },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
/**
* Current company object from companyAutomat, or null.
* Previously: companies array from GET /api/company.
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
*/
company: { get: () => companyAutomat.state.current ?? null },
/** Promise that resolves when company data is loaded. */
companyPromise: {
get: () => companyAutomat.state.status === 'succeeded'
? Promise.resolve(companyAutomat.state.current)
: new Promise((resolve, reject) => {
const unsub = companyAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload company data from API. */
updateCompany: { value: () => companyAutomat.actions.load() },
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
// Return a single-element array so .find() still works.
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
// ── Category tree bridge ─────────────────────────────────────────────────
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
// ── Remaining non-auth-gated caches ─────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */
@@ -456,7 +512,62 @@ export class StorageManager extends ApiClient {
/** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() },
});
// Trigger articleIndex load once auth is established
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
if (!articleIndexCache.value) {
void articleIndexCache.update?.().catch(() => {});
}
}
});
}
/**
* Create a cache that does NOT load immediately but waits for an explicit trigger.
* Differs from createCache in that the initial load promise is not started.
*/
#createDeferredCache(label, load) {
let current;
let resolveInitial;
let rejectInitial;
let promise = new Promise((res, rej) => {
resolveInitial = res;
rejectInitial = rej;
});
async function update() {
try {
const data = await load();
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
rejectInitial?.(err);
rejectInitial = null;
throw err;
}
}
function replace(data) {
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
}
return {
update,
replace,
get promise() { return promise; },
get value() { return current; },
};
}
/**
* GET /api/events — SSE signals for the selected company.

View File

@@ -0,0 +1,81 @@
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} BrandState
* @property {Array<object>} brands
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {BrandState} */
const initialState = {
brands: [],
status: 'idle',
error: null,
};
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
let brandAutomat;
const actions = {
/** Load all brands for the selected company. */
async load() {
brandAutomat.setState({ status: 'loading', error: null });
try {
const res = await brandApi.list();
const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []);
brandAutomat.setState({ brands, status: 'succeeded' });
return brands;
} catch (err) {
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert a brand (create or update by name).
* @param {{ name: string, gsprpinfo?: string }} body
*/
async upsert(body) {
const res = await brandApi.upsert(body);
const row = res.brand;
const brands = sortBrands([
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
row,
]);
brandAutomat.setState({ brands });
return row;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
brandAutomat.setState(initialState);
},
};
/**
* Global brand state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<BrandState>}
*/
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void brandAutomat.actions.load();
} else if (auth.status === 'anon') {
brandAutomat.actions.reset();
}
});
export default brandAutomat;

View File

@@ -0,0 +1,490 @@
import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils';
/**
* @typedef {Object} CategoryState
* @property {number|string|null} companyId
* @property {string|null} languageId
* @property {Record<string,object>|null} categories id → node map
* @property {number|string|null} rootCategoryId
* @property {Array<object>} rootArticles
* @property {number|string|null} currentCategoryId
* @property {Array<object>} articles
* @property {number} articlesTotal
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
* @property {string|null} error
*/
/** @type {CategoryState} */
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
let categoryAutomat;
// ─── Pure helpers ──────────────────────────────────────────────────────────────
function articlesForCategory(state, categoryId) {
if (String(categoryId) === String(state.rootCategoryId)) {
return state.rootArticles;
}
return [];
}
// ─── Actions ───────────────────────────────────────────────────────────────────
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
const company = companyAutomat.state.current;
if (!company) return;
const { id: companyId, rootCategoryId: companyRootId } = company;
if (!companyRootId) return;
categoryAutomat.setState({ treeStatus: 'loading', error: null });
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
currentCategoryId: parsed.rootCategoryId,
rootArticles: parsed.rootArticles,
articles: parsed.rootArticles,
articlesTotal: parsed.rootArticles.length,
articlesStatus: 'succeeded',
error: null,
});
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
},
/**
* Load articles for a non-root category.
* @param {number|string} categoryId
*/
async loadArticles(categoryId) {
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
try {
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
categoryAutomat.setState({
articlesStatus: 'succeeded',
articles,
articlesTotal: articles.length,
});
} catch (err) {
categoryAutomat.setState({
articlesStatus: 'failed',
articles: [],
articlesTotal: 0,
error: err?.message ?? String(err),
});
throw err;
}
},
/** Navigate to a category by id. */
navigateTo(categoryId) {
const { rootCategoryId, rootArticles } = categoryAutomat.state;
const isRoot = String(categoryId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: categoryId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
},
/** Navigate up to the parent category. */
navigateBack() {
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const current = categories?.[String(currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
const isRoot = String(parentId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: parentId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
}
},
/** Toggle rename/edit mode for the category grid. */
setRenameMode(enabled) {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
},
// ─── Category mutations ────────────────────────────────────────────────────
/**
* Create a single category.
* @param {{ parentId: number|string, name: string }} param0
*/
async createCategory({ parentId, name }) {
const res = await categoryApi.create({ parentId, name });
const node = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
categoryAutomat.setState({
categories: { ...(categories ?? {}), [String(node.id)]: node },
});
return node;
},
/**
* Create multiple categories under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createCategories({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await categoryApi.create({ parentId, name });
created.push(normalizeCategoryNode(res.category));
}
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
categoryAutomat.setState({
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch },
});
return created;
},
/**
* Create a single article.
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
if (String(created.parentId) === String(currentCategoryId)) {
patch.articles = [...articles, created];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(rootCategoryId)) {
patch.rootArticles = [...rootArticles, created];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
/**
* Create multiple articles under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createArticles({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
if (forCurrent.length > 0) {
patch.articles = [...articles, ...forCurrent];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
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;
},
/**
* Rename a category.
* @param {{ categoryId: number|string, name: string }} param0
*/
async renameCategory({ categoryId, name }) {
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const res = await categoryApi.updateName(categoryId, trimmed);
const updated = normalizeCategoryNode(res.category);
const key = String(updated.id);
const { categories, rootArticles } = categoryAutomat.state;
categoryAutomat.setState({
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
rootArticles: rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name: updated.name }
: row,
),
});
return updated;
},
/**
* Reorder sibling categories.
* @param {{ categoryIds: number[] }} param0
*/
async reorderCategories({ categoryIds }) {
const { currentCategoryId, categories } = categoryAutomat.state;
const res = await categoryApi.reorder(currentCategoryId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(currentCategoryId);
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
const patched = { ...categories };
for (const node of Object.values(patched)) {
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
}
}
categoryAutomat.setState({ categories: patched });
return ordered;
},
/**
* Move a category under a new parent.
* @param {{ categoryId: number|string, parentId: number|string }} param0
*/
async moveCategory({ categoryId, parentId }) {
const res = await categoryApi.move(categoryId, parentId);
const moved = res.category;
const { categories } = categoryAutomat.state;
if (!categories || !moved) return;
const key = String(moved.id);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
},
});
return moved;
},
/**
* Delete a category by id.
* @param {number|string} categoryId
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
if (!categories) return;
const next = { ...categories };
delete next[String(categoryId)];
const patch = { categories: next };
if (String(currentCategoryId) === String(categoryId)) {
patch.currentCategoryId = rootCategoryId;
patch.articles = rootArticles;
patch.articlesTotal = rootArticles.length;
patch.articlesStatus = 'succeeded';
}
categoryAutomat.setState(patch);
return categoryId;
},
/**
* Toggle the POS flag on a category.
* @param {{ categoryId: number|string, pos: boolean }} param0
*/
async toggleCategoryPos({ categoryId, pos }) {
const res = await categoryApi.updatePos(categoryId, pos);
const updated = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
if (!categories) return;
const key = String(updated.id);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } },
});
return updated;
},
/**
* Upload an image for a category.
* @param {{ categoryId: number|string, file: Blob }} param0
*/
async uploadCategoryImage({ categoryId, file }) {
const res = await categoryApi.uploadImage(categoryId, file);
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } },
});
return res.category;
},
/**
* Delete the image for a category.
* @param {number|string} categoryId
*/
async deleteCategoryImage(categoryId) {
const res = await categoryApi.deleteImage(categoryId);
const { id } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
},
});
return res.category;
},
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from both article lists (used after article deletion). */
removeArticleBySku(sku) {
categoryAutomat.setState({
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
});
},
/**
* Patch article fields and category membership in local lists.
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
*/
patchArticleInLists({ sku, categoryIds, ...fields }) {
if (sku == null) return;
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
const patchList = (list) =>
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
let nextArticles = patchList(articles);
let nextRoot = patchList(rootArticles);
let nextArticlesTotal = nextArticles.length;
if (categoryIds !== undefined) {
const newIds = categoryIds ?? [];
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
if (rootCategoryId != null) {
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
const wasInRoot = nextRoot.some((r) => r.sku === sku);
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
}
if (currentCategoryId != null) {
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
nextArticlesTotal = nextArticles.length;
}
}
categoryAutomat.setState({
articles: nextArticles,
articlesTotal: nextArticlesTotal,
rootArticles: nextRoot,
});
},
/**
* Patch posterminalIds on a category node.
* @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0
*/
patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) {
const { categories } = categoryAutomat.state;
const cat = categories?.[String(categoryId)];
if (!cat) return;
const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : [];
const next = enabled
? current.includes(terminalId) ? current : [...current, terminalId]
: current.filter((id) => id !== terminalId);
categoryAutomat.setState({
categories: {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
},
});
},
/** Reset state to idle (logout / company switch). */
reset() {
categoryAutomat.setState(initialState);
},
};
/**
* Global category tree state container.
* Loads automatically when companyAutomat has a company with a rootCategoryId.
*
* @type {Automat<CategoryState>}
*/
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// Load tree whenever the company data changes (or first becomes available)
let _lastCompanyId = null;
companyAutomat.subscribe((company) => {
const id = company.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Reset tree on logout
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Re-export pure helpers for consumers that previously imported from categorySlice
export {
ROOT_CATEGORY_NAME,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
export default categoryAutomat;

View File

@@ -0,0 +1,96 @@
import { Automat } from 'react-automat';
import { company as companyApi } from '../api';
import authAutomat from './authAutomat';
import { normalizeCompany } from '../utils/companyForm';
/**
* @typedef {Object} CompanyState
* @property {object|null} current Normalized company object for the selected company.
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {CompanyState} */
const initialState = {
current: null,
status: 'idle',
error: null,
};
let companyAutomat;
const actions = {
/**
* Load the company data for the currently selected company.
* @returns {Promise<object|null>}
*/
async load() {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
companyAutomat.setState({ status: 'loading', error: null });
try {
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Update company details and refresh cached data.
* @param {object} payload
*/
async update(payload) {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
companyAutomat.setState({ status: 'loading', error: null });
try {
await companyApi.update(companyId, payload);
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company, payload);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/** Reset to idle (called on logout / company switch). */
reset() {
companyAutomat.setState(initialState);
},
};
/**
* Global company state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<CompanyState>}
*/
companyAutomat = new Automat(initialState, actions, { name: 'company' });
// Subscribe to auth changes — load when authed, reset when logged out / anon
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
// Selectors (plain functions for use outside React)
export const selectCurrentCompany = () => companyAutomat.state.current;
export const selectCompanyId = () => companyAutomat.state.current?.id ?? null;
export const selectCompanyStatus = () => companyAutomat.state.status;
export const selectCompanyName = () => companyAutomat.state.current?.name;
export const selectCompanyIso639 = () => companyAutomat.state.current?.iso639;
export const selectCompanyTaxrate = () => companyAutomat.state.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = () => companyAutomat.state.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = () => companyAutomat.state.current?.rootCategoryId ?? null;
export default companyAutomat;

View File

@@ -1,11 +1,16 @@
export * from './deliveryEditorAutomat';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as artikelAutomat } from './artikelAutomat';
export {
getArticle,
@@ -13,3 +18,37 @@ export {
selectArticle,
createArticleSelector,
} from './artikelAutomat';
export { default as companyAutomat } from './companyAutomat';
export {
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
selectCompanyName,
selectCompanyIso639,
selectCompanyTaxrate,
selectCompanyReducedTaxrate,
selectCompanyRootCategoryId,
} from './companyAutomat';
export { default as brandAutomat } from './brandAutomat';
export { default as unitAutomat } from './unitAutomat';
export { default as posterminalAutomat } from './posterminalAutomat';
export {
selectPosterminals,
selectPosterminalStatus,
selectHasPosterminals,
} from './posterminalAutomat';
export { default as categoryAutomat } from './categoryAutomat';
export {
ROOT_CATEGORY_NAME,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
} from './categoryAutomat';

View File

@@ -0,0 +1,142 @@
import { Automat } from 'react-automat';
import { posterminal as posterminalApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} PosterminalState
* @property {Array<object>} terminals
* @property {number|string|null} companyId
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PosterminalState} */
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
let posterminalAutomat;
const actions = {
/** Load all posterminals for the selected company. */
async load() {
const companyId = companyAutomat.state.current?.id;
if (!companyId) return [];
posterminalAutomat.setState({ status: 'loading', error: null });
try {
const res = await posterminalApi.list();
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
return terminals;
} catch (err) {
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create a new posterminal.
* @param {{ name: string }} param0
*/
async create({ name }) {
const res = await posterminalApi.create({ name });
const terminal = res.posterminal;
posterminalAutomat.setState({
terminals: [...posterminalAutomat.state.terminals, terminal],
});
return terminal;
},
/**
* Delete a posterminal.
* @param {number} terminalId
*/
async delete(terminalId) {
await posterminalApi.delete(terminalId);
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
});
},
/**
* Enable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async enableAll({ terminalId }) {
const res = await posterminalApi.enableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async disableAll({ terminalId }) {
const res = await posterminalApi.disableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Enable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async enableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.enableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async disableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.disableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
posterminalAutomat.setState(initialState);
},
};
function patchTerminalCategories(terminalId, categoryIds) {
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, categoryIds } : t,
),
});
}
/**
* Global posterminal state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<PosterminalState>}
*/
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void posterminalAutomat.actions.load();
} else if (auth.status === 'anon') {
posterminalAutomat.actions.reset();
}
});
// Selectors
export const selectPosterminals = () => posterminalAutomat.state.terminals;
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
export default posterminalAutomat;

View File

@@ -0,0 +1,94 @@
import { Automat } from 'react-automat';
import { unit as unitApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} UnitState
* @property {Array<object>} units
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {UnitState} */
const initialState = {
units: [],
status: 'idle',
error: null,
};
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
let unitAutomat;
const actions = {
/** Load all units for the selected company. */
async load() {
unitAutomat.setState({ status: 'loading', error: null });
try {
const res = await unitApi.list();
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
unitAutomat.setState({ units, status: 'succeeded' });
return units;
} catch (err) {
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert (create or update) a unit.
* @param {object} body
*/
async upsert(body) {
const res = await unitApi.upsert(body);
const row = res.unit;
const units = sortUnits([
...unitAutomat.state.units.filter((u) => u.si !== row.si),
row,
]);
unitAutomat.setState({ units });
return units;
},
/**
* Delete a unit by SI symbol.
* @param {string} si
*/
async delete(si) {
await unitApi.delete(si);
unitAutomat.setState({
units: unitAutomat.state.units.filter((u) => u.si !== si),
});
},
/** Reset to idle state (called on logout / company switch). */
reset() {
unitAutomat.setState(initialState);
},
};
/**
* Global unit state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<UnitState>}
*/
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void unitAutomat.actions.load();
} else if (auth.status === 'anon') {
unitAutomat.actions.reset();
}
});
export default unitAutomat;

View File

@@ -1,6 +1,5 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import MenuList from '@mui/material/MenuList';
@@ -14,13 +13,7 @@ import { enqueueSnackbar } from 'notistack';
import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
import { isKategorienPath } from '../pages/artikel/categoryRoute';
import {
selectRenameMode,
setRenameMode,
selectCategoryMap,
selectCategoryRootId,
loadCategoryTree,
} from '../store/categorySlice';
import categoryAutomat from '../automats/categoryAutomat';
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
import { article, category } from '../api';
import { importArtikelCsv } from '../utils/importArtikel';
@@ -37,15 +30,31 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url);
}
function selectCatSlice(s) {
return {
renameMode: s.renameMode,
categories: s.categories,
rootCategoryId: s.rootCategoryId,
};
}
export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation();
const dispatch = useDispatch();
const renameMode = useSelector(selectRenameMode);
const categories = useSelector(selectCategoryMap);
const rootCategoryId = useSelector(selectCategoryRootId);
const importInputRef = useRef(null);
const [extrasAnchor, setExtrasAnchor] = useState(null);
const [importing, setImporting] = useState(false);
const [catState, setCatState] = useState(() => selectCatSlice(categoryAutomat.state));
useEffect(() => {
// Automat supports callback subscriptions: (state, partial) => void
const unsub = categoryAutomat.subscribe((state) => {
setCatState(selectCatSlice(state));
}, selectCatSlice);
return unsub;
}, []);
const { renameMode, categories, rootCategoryId } = catState;
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
const subNav = SUB_NAV[sectionPath];
const subSegment = resolveActiveSub(pathname, sectionPath);
@@ -109,7 +118,7 @@ export default function SectionLayout({ sectionPath }) {
},
});
if (result.ok > 0 && rootCategoryId != null) {
await dispatch(loadCategoryTree()).unwrap();
await categoryAutomat.actions.loadTree();
}
if (result.ok > 0) {
enqueueSnackbar(
@@ -136,7 +145,7 @@ export default function SectionLayout({ sectionPath }) {
<ToggleElevatedPill
label="Edit"
checked={renameMode}
onToggle={(checked) => dispatch(setRenameMode(checked))}
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
/>
<ActionElevatedPill
label="Extras"

View File

@@ -14,7 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import Box from '@mui/material/Box';
import { getCategoryChildren, categoryDisplayName } from '../../store/categorySlice';
import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
function CategoryNode({
categories,
categoryId,

View File

@@ -1,10 +1,6 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import {
createArticles as createArticlesThunk,
createArticle as createArticleThunk,
} from '../../store/categorySlice';
import categoryAutomat from '../../automats/categoryAutomat';
import ArticleNameDialog from './ArticleNameDialog';
class NewArticleTile extends Component {
@@ -28,15 +24,16 @@ class NewArticleTile extends Component {
const { parentId } = this.props;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const article = await this.props
.createArticle({ parentId, name: list[0] ?? '' })
.unwrap();
const article = await categoryAutomat.actions.createArticle({
parentId,
name: list[0] ?? '',
});
if (article) {
this.props.onOpenArticle?.(article);
}
return;
}
const created = await this.props.createArticles({ parentId, names: list }).unwrap();
const created = await categoryAutomat.actions.createArticles({ parentId, names: list });
if (created?.[0]) {
this.props.onOpenArticle?.(created[0]);
}
@@ -62,9 +59,4 @@ class NewArticleTile extends Component {
}
}
const mapDispatchToProps = {
createArticle: createArticleThunk,
createArticles: createArticlesThunk,
};
export default connect(null, mapDispatchToProps)(NewArticleTile);
export default NewArticleTile;

View File

@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog';

View File

@@ -169,12 +169,7 @@ function priceDraftValid(draft) {
function companyFromStore(store) {
if (!store) return null;
const companies = store.companies;
const session = store.authMe;
if (companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
return companies.find((c) => c.id === companyId) ?? null;
return store.company ?? null;
}
class PriceAmountField extends Component {
@@ -395,18 +390,12 @@ export default class ArticleViewPrice extends PureComponent {
async load() {
const store = this.context;
try {
const [unitList, companies, session] = await Promise.all([
const [unitList, company] = await Promise.all([
store.unitPromise,
store.companyPromise,
store.authMePromise,
]);
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const current = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
if (!this._cancelled) {
this.setState({ units: unitList, company: current });
this.setState({ units: unitList, company: company ?? null });
}
} catch (err) {
if (!this._cancelled) notifyError(err);

View File

@@ -1,10 +1,6 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import {
createCategories as createCategoriesThunk,
createCategory as createCategoryThunk,
} from '../../store/categorySlice';
import categoryAutomat from '../../automats/categoryAutomat';
import CategoryNameDialog from './CategoryNameDialog';
class NewCategoryTile extends Component {
@@ -28,10 +24,10 @@ class NewCategoryTile extends Component {
const { parentId } = this.props;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap();
await categoryAutomat.actions.createCategory({ parentId, name: list[0] ?? '' });
return;
}
await this.props.createCategories({ parentId, names: list }).unwrap();
await categoryAutomat.actions.createCategories({ parentId, names: list });
}
render() {
@@ -55,9 +51,4 @@ class NewCategoryTile extends Component {
}
}
const mapDispatchToProps = {
createCategory: createCategoryThunk,
createCategories: createCategoriesThunk,
};
export default connect(null, mapDispatchToProps)(NewCategoryTile);
export default NewCategoryTile;

View File

@@ -8,8 +8,43 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import { generateTextThumbnail } from '../../utils/textThumbnail';
export class Attachment extends PureComponent {
state = {
generatedThumbnail: null,
};
componentDidMount() {
this.ensureThumbnail();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.ensureThumbnail();
}
}
async ensureThumbnail() {
const { attachment } = this.props;
if (!attachment || attachment.thumbnail) return;
const isCsv = attachment.type === 'text/csv' || attachment.name?.toLowerCase().endsWith('.csv');
const isText = attachment.type?.startsWith('text/') || attachment.name?.toLowerCase().endsWith('.txt') || isCsv;
if (!isText) return;
const source = attachment.text || attachment.file;
if (!source) return;
try {
const thumb = await generateTextThumbnail(source);
if (thumb && this.props.attachment === attachment) {
this.setState({ generatedThumbnail: thumb });
}
} catch (err) {
console.error('Fehler beim Generieren des Text-Thumbnails:', err);
}
}
handleRescan = (e) => {
e?.stopPropagation?.();
const { attachment, onRescan } = this.props;
@@ -56,7 +91,7 @@ export class Attachment extends PureComponent {
render() {
const { attachment, disabled, sx } = this.props;
const thumbnail = attachment?.thumbnail;
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
const name = attachment?.name || 'Dokument';
const size = attachment?.size;
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');

View File

@@ -341,10 +341,18 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
}
}
let thumbavif = null;
if (attachment.thumbnail) {
thumbavif = attachment.thumbnail.includes(',')
? attachment.thumbnail.split(',')[1]
: attachment.thumbnail;
}
files.push({
file: fileToUpload,
name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type,
thumbavif,
});
}
return files;
@@ -389,6 +397,10 @@ export async function buildManifestBody(
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) {
const formData = new FormData();
body.filesMeta = filesToUpload.map((item) => ({
name: item.name,
thumbavif: item.thumbavif ?? null,
}));
formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) {
formData.append('files', item.file, item.name);

View File

@@ -3,12 +3,10 @@ import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
import { SnackbarProvider } from 'notistack';
import AuthBootstrap from './components/AuthBootstrap';
import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App';
import { store } from './store';
import { getActiveTheme } from './theme';
import './automats/supplierAutomat';
import './index.css';
@@ -34,7 +32,6 @@ if (import.meta.env.DEV) {
}
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<ThemeProvider theme={getActiveTheme()}>
<CssBaseline />
<SnackbarProvider
@@ -59,6 +56,5 @@ createRoot(document.getElementById('root')).render(
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>
</ThemeProvider>
</Provider>,
</ThemeProvider>,
);

View File

@@ -3,7 +3,7 @@ import {
categorySlug,
getCategoryBreadcrumbs,
getCategoryChildren,
} from '../../store/categorySlice';
} from '../../utils/categoryUtils';
export { ROOT_CATEGORY_NAME, categorySlug };

View File

@@ -1,5 +1,4 @@
import { Component, createRef } from 'react';
import { connect } from 'react-redux';
import { Component, PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
@@ -19,41 +18,49 @@ import {
import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
import {
buildCategoryPath,
defaultCategoryPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import {
categoryDisplayName,
deleteCategory,
moveCategory,
loadCategoryArticles,
loadCategoryTree,
navigateToCategory,
renameCategory,
reorderCategories,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
selectCategoryBreadcrumbs,
selectCategoryMap,
selectCategoryError,
selectCategoryTreeStatus,
selectChildCategories,
selectRenameMode,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
} from '../../store/categorySlice';
import {
enableTerminalCategory,
disableTerminalCategory,
} from '../../store/posterminalSlice';
import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s;
const breadcrumbs = getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, currentCategoryId);
const canGoBack =
currentCategoryId != null &&
rootCategoryId != null &&
String(currentCategoryId) !== String(rootCategoryId) &&
categories?.[String(currentCategoryId)]?.parentId != null &&
categories[String(currentCategoryId)].parentId !== 0;
return {
categories,
rootCategoryId,
treeStatus,
articlesStatus,
error,
currentCategoryId,
breadcrumbs,
childCategories,
articles,
articlesTotal,
canGoBack,
renameMode,
};
}
function selectTerminals(s) {
return { terminals: s.terminals };
}
const gridSx = {
display: 'grid',
@@ -61,10 +68,15 @@ const gridSx = {
gap: 2,
};
class ArtikelKategorienPage extends Component {
class ArtikelKategorienPage extends PureComponent {
constructor(props) {
super(props);
this.state = { drafts: {} };
this.state = {
drafts: {},
...selectFromCategory(categoryAutomat.state),
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
};
this.gridRef = createRef();
this.sortableModule = null;
this.droppableModule = null;
@@ -74,6 +86,11 @@ class ArtikelKategorienPage extends Component {
componentDidMount() {
setLastArticleSku(null);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
companyId: s.current?.id ?? null,
}));
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl();
@@ -82,37 +99,36 @@ class ArtikelKategorienPage extends Component {
this.syncDroppable();
}
componentDidUpdate(prevProps) {
if (this.props.companyId !== prevProps.companyId) {
componentDidUpdate(prevState) {
// Note: PureComponent — comparisons use this.state not this.props
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded();
}
if (
this.props.treeStatus === 'succeeded' &&
prevProps.treeStatus !== 'succeeded'
) {
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
}
if (
this.props.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== prevProps.categorySlugPath
this.state.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
) {
this.syncCategoryFromUrl();
}
this._prevSlugPath = this.props.categorySlugPath;
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
if (this.state.currentCategoryId !== prevState.currentCategoryId) {
this.ensureArticlesLoaded();
}
if (this.props.renameMode !== prevProps.renameMode) {
if (this.state.renameMode !== prevState.renameMode) {
this.syncSortable();
this.syncDroppable();
}
}
componentWillUnmount() {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this.sortableModule?.destroy();
this.droppableModule?.destroy();
}
@@ -122,19 +138,21 @@ class ArtikelKategorienPage extends Component {
if (!el) return;
if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, {
onReorder: this.props.reorderCategories,
onReorder: ({ categoryIds }) =>
categoryAutomat.actions.reorderCategories({ categoryIds }),
});
}
if (!this.droppableModule) {
this.droppableModule = new CategoryDroppable(el, {
onMove: this.props.moveCategory,
onMove: ({ categoryId, parentId }) =>
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
});
}
};
syncSortable = () => {
this.#ensureModules();
if (this.props.renameMode) {
if (this.state.renameMode) {
this.sortableModule?.destroy();
this.sortableModule?.activate();
} else {
@@ -144,7 +162,7 @@ class ArtikelKategorienPage extends Component {
syncDroppable = () => {
this.#ensureModules();
if (this.props.renameMode) {
if (this.state.renameMode) {
this.droppableModule?.destroy();
this.droppableModule?.activate();
} else {
@@ -157,33 +175,32 @@ class ArtikelKategorienPage extends Component {
};
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props;
const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return;
this.props.loadCategoryTree();
void categoryAutomat.actions.loadTree();
};
ensurePosterminalsLoaded = () => {
const { companyId } = this.props;
const { companyId } = this.state;
if (!companyId) return;
this.props.loadPosterminals();
void posterminalAutomat.actions.load();
};
ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props;
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state;
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
if (String(currentCategoryId) === String(rootCategoryId)) return;
this.props.loadCategoryArticles(currentCategoryId);
void categoryAutomat.actions.loadArticles(currentCategoryId);
};
syncCategoryFromUrl = () => {
const {
categories,
rootCategoryId,
categorySlugPath,
currentCategoryId,
treeStatus,
navigate,
} = this.props;
} = this.state;
const { categorySlugPath, navigate } = this.props;
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
@@ -206,13 +223,13 @@ class ArtikelKategorienPage extends Component {
}
if (resolvedId !== currentCategoryId) {
this.props.navigateToCategory(resolvedId);
categoryAutomat.actions.navigateTo(resolvedId);
}
};
navigateToCategoryById = (categoryId) => {
const { categories, rootCategoryId, navigate } = this.props;
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
const { categories, rootCategoryId } = this.state;
this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
};
handleDraftChange(categoryId, value) {
@@ -228,10 +245,7 @@ class ArtikelKategorienPage extends Component {
return;
}
try {
await this.props.renameCategory({
categoryId,
name: trimmed,
}).unwrap();
await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
} catch (err) {
notifyError(err);
@@ -240,7 +254,7 @@ class ArtikelKategorienPage extends Component {
handleDeleteCategory = async (category) => {
try {
await this.props.deleteCategory(category.id).unwrap();
await categoryAutomat.actions.deleteCategory(category.id);
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
@@ -248,18 +262,18 @@ class ArtikelKategorienPage extends Component {
};
handleRetryTree = () => {
if (this.props.companyId) this.props.loadCategoryTree();
if (this.state.companyId) void categoryAutomat.actions.loadTree();
};
handleRetryArticles = () => {
const { currentCategoryId } = this.props;
const { currentCategoryId } = this.state;
if (currentCategoryId) {
this.props.loadCategoryArticles(currentCategoryId);
void categoryAutomat.actions.loadArticles(currentCategoryId);
}
};
handleNavigateBack = () => {
const { breadcrumbs } = this.props;
const { breadcrumbs } = this.state;
if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
};
@@ -283,7 +297,8 @@ class ArtikelKategorienPage extends Component {
articles,
renameMode,
terminals,
} = this.props;
drafts,
} = this.state;
if (!companyId) {
return (
@@ -347,8 +362,8 @@ class ArtikelKategorienPage extends Component {
{childCategories.map((item, index) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
const draftName =
this.state.drafts[item.id] !== undefined
? this.state.drafts[item.id]
drafts[item.id] !== undefined
? drafts[item.id]
: label;
const editProps = renameMode
? {
@@ -359,13 +374,12 @@ class ArtikelKategorienPage extends Component {
terminals,
categoryPosterminalIds: item.posterminalIds || [],
onTerminalToggle: (terminalId, enabled) => {
const thunk = enabled
? this.props.enableTerminalCategory
: this.props.disableTerminalCategory;
void thunk({ terminalId, categoryId: item.id })
.unwrap()
const action = enabled
? posterminalAutomat.actions.enableCategory
: posterminalAutomat.actions.disableCategory;
void action({ terminalId, categoryId: item.id })
.then(() => {
this.props.patchCategoryPosterminalIds({
categoryAutomat.actions.patchCategoryPosterminalIds({
categoryId: item.id,
terminalId,
enabled,
@@ -382,10 +396,9 @@ class ArtikelKategorienPage extends Component {
const file = e.target.files?.[0];
if (!file) return;
try {
// Prepare image (resize and encode to AVIF)
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
const avifFile = await prepareArticlePhoto(file);
await this.props.uploadCategoryImage({
await categoryAutomat.actions.uploadCategoryImage({
categoryId: item.id,
file: avifFile,
});
@@ -396,7 +409,7 @@ class ArtikelKategorienPage extends Component {
input.click();
},
onPhotoDelete: () => {
void this.props.deleteCategoryImage(item.id)
void categoryAutomat.actions.deleteCategoryImage(item.id)
.catch(notifyError);
},
nameInput: (
@@ -478,36 +491,4 @@ class ArtikelKategorienPage extends Component {
}
}
const mapStateToProps = (state) => ({
companyId: selectCompanyId(state),
categories: selectCategoryMap(state),
rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId,
breadcrumbs: selectCategoryBreadcrumbs(state),
childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state),
terminals: selectPosterminals(state),
});
const mapDispatchToProps = {
loadCategoryTree,
loadCategoryArticles,
loadPosterminals,
navigateToCategory,
renameCategory,
deleteCategory,
moveCategory,
reorderCategories,
enableTerminalCategory,
disableTerminalCategory,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
};
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
export default withCategoryRoute(ArtikelKategorienPage);

View File

@@ -31,6 +31,7 @@ import { requireArray, requireDefined, requireObject } from '../../utils/apiShap
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
@@ -477,12 +478,19 @@ class LieferungNeuPage extends PureComponent {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(),
@@ -499,6 +507,12 @@ class LieferungNeuPage extends PureComponent {
} catch (err) {
console.error('Fehler beim Lesen der Datei:', err);
}
let thumbnail = null;
try {
thumbnail = await generateTextThumbnail(text || file);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const textAttachment = {
id: attachmentId,
@@ -506,6 +520,7 @@ class LieferungNeuPage extends PureComponent {
name: file.name,
size: file.size,
type: isCsv ? 'text/csv' : 'text/plain',
thumbnail,
text,
uploadedAt: new Date().toISOString(),
};
@@ -537,6 +552,13 @@ class LieferungNeuPage extends PureComponent {
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', thumbErr);
}
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
@@ -546,6 +568,7 @@ class LieferungNeuPage extends PureComponent {
file: txtFile,
size: txtFile.size,
text,
thumbnail: txtThumbnail || existingTxt.thumbnail,
isDirty: true,
});
} else {
@@ -555,6 +578,7 @@ class LieferungNeuPage extends PureComponent {
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
@@ -996,7 +1020,7 @@ class LieferungNeuPage extends PureComponent {
);
}
handleSaveTextEditor(newText) {
async handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
@@ -1004,11 +1028,19 @@ class LieferungNeuPage extends PureComponent {
type: 'text/plain;charset=utf-8',
});
let thumbnail = attachment.thumbnail;
try {
thumbnail = await generateTextThumbnail(newText);
} catch (err) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', err);
}
this.automat.actions.updateAttachment({
id: attachment.id,
file: newFile,
size: newFile.size,
text: newText,
thumbnail,
isDirty: true,
});

View File

@@ -1,94 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { brand as brandApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import { catchBackendError } from '../utils/backendError';
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
export const loadBrands = createAsyncThunk('brand/load', async (_, { rejectWithValue }) => {
try {
const res = await brandApi.list();
requireObject(res, 'GET /api/brand');
return requireArray(res.brands, 'brands');
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
});
export const upsertBrand = createAsyncThunk(
'brand/upsert',
async (body, { rejectWithValue }) => {
try {
const res = await brandApi.upsert(body);
requireObject(res, 'PUT /api/brand');
requireObject(res.brand, 'brand');
return res.brand;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
brands: [],
status: 'idle',
error: null,
};
const brandSlice = createSlice({
name: 'brand',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadBrands.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadBrands.fulfilled, (state, action) => {
state.status = 'succeeded';
state.brands = sortBrands(action.payload);
})
.addCase(loadBrands.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertBrand.fulfilled, (state, action) => {
const row = action.payload;
state.brands = sortBrands([
...state.brands.filter((b) => b.id !== row.id),
row,
]);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectBrands = (state) => state.brand.brands;
export const selectBrandsStatus = (state) => state.brand.status;
export const selectBrandsError = (state) => state.brand.error;
export function brandLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadBrands());
}
},
});
}
export default brandSlice.reducer;

View File

@@ -1,696 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { category, article } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { AUTH_LOGGED_OUT } from '../automats/authAutomat';
import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
requireObject(item, 'category');
requireDefined(item.id, 'category.id');
return {
...item,
id: item.id,
parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null,
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
};
}
/** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
requireObject(data, 'category/tree');
const list = requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
requireDefined(rootCategoryId, 'rootCategoryId');
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
return {
rootCategoryId,
categories,
rootArticles: data.articles,
languageId: data.languageId,
};
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
export function categorySlug(node, rootCategoryId) {
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return node.slug || null;
}
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
if (!categories || currentCategoryId == null) return [];
const crumbs = [];
const seen = new Set();
let id = currentCategoryId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = categories[String(id)];
if (!node) break;
crumbs.unshift({
id: node.id,
name: categoryDisplayName(node.name, node.id, rootCategoryId),
slug: categorySlug(node, rootCategoryId),
});
const parentId = node.parentId;
if (parentId == null || parentId === 0) break;
if (String(parentId) === String(id)) break;
id = parentId;
}
return crumbs;
}
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
const companyRootId = getState().company.current?.rootCategoryId;
requireDefined(companyRootId, 'company.rootCategoryId');
const data = await category.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
return {
companyId,
...parsed,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
async (categoryId, { rejectWithValue }) => {
try {
const data = await category.getArticles(categoryId);
requireObject(data, `category/${categoryId}/articles`);
const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`);
return { categoryId, articles };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const createCategory = createAsyncThunk(
'category/create',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create several categories under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every category is created
* even if a later one fails; the first error is reported.
* Returns the array of created (normalized) categories.
*/
export const createCategories = createAsyncThunk(
'category/createCategories',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
created.push(normalizeCategoryNode(data.category));
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created categories so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const createArticle = createAsyncThunk(
'category/createArticle',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
return data.article;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create several articles under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every article is created
* even if a later one fails; the first error is reported.
* Returns the array of created articles.
*/
export const createArticles = createAsyncThunk(
'category/createArticles',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
created.push(data.article);
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created articles so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const renameCategory = createAsyncThunk(
'category/rename',
async ({ categoryId, name }, { rejectWithValue }) => {
try {
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const data = await category.updateName(categoryId, trimmed);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Reorder sibling categories (new order of children under the current parent).
* `PUT /api/category/order` → 200 { categoryIds: number[] }
*/
export const reorderCategories = createAsyncThunk(
'category/reorder',
async ({ categoryIds }, { rejectWithValue, getState }) => {
try {
const ids = requireArray(categoryIds, 'categoryIds');
const parentId = getState().category.currentCategoryId;
requireDefined(parentId, 'currentCategoryId');
const data = await category.reorder(parentId, ids);
requireObject(data, 'PUT /api/category/order');
requireArray(data.categoryIds, 'categoryIds');
return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Move a category under a new parent (drop into subcategory).
* Backend: PATCH /api/category/:id Body: { parentId } → { category }.
* (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.)
*/
export const moveCategory = createAsyncThunk(
'category/move',
async ({ categoryId, parentId }, { rejectWithValue }) => {
try {
const data = await category.move(categoryId, parentId);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk(
'category/delete',
async (categoryId, { rejectWithValue }) => {
try {
await category.delete(categoryId);
return categoryId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Toggle the POS (Point of Sale) flag on a category.
* Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }.
* PROPOSED — backend needs to implement this field.
*/
export const toggleCategoryPos = createAsyncThunk(
'category/togglePos',
async ({ categoryId, pos }, { rejectWithValue }) => {
try {
const data = await category.updatePos(categoryId, pos);
requireObject(data.category, 'PATCH /api/category/:id.category (pos)');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* POST /api/category/:id/image → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
export const uploadCategoryImage = createAsyncThunk(
'category/uploadImage',
async ({ categoryId, file }, { rejectWithValue }) => {
try {
const data = await category.uploadImage(categoryId, file);
requireObject(data.category, 'POST /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* DELETE /api/category/:id/image → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
export const deleteCategoryImage = createAsyncThunk(
'category/deleteImage',
async (categoryId, { rejectWithValue }) => {
try {
const data = await category.deleteImage(categoryId);
requireObject(data.category, 'DELETE /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
const categorySlice = createSlice({
name: 'category',
initialState,
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
state.error = null;
if (String(action.payload) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) {
state.currentCategoryId = parentId;
state.error = null;
if (String(parentId) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
}
},
resetCategoryState() {
return initialState;
},
patchCategoryPosterminalIds(state, action) {
const { categoryId, terminalId, enabled } = action.payload;
const cat = state.categories?.[String(categoryId)];
if (!cat) return;
if (!Array.isArray(cat.posterminalIds)) cat.posterminalIds = [];
if (enabled && !cat.posterminalIds.includes(terminalId)) {
cat.posterminalIds.push(terminalId);
} else if (!enabled) {
cat.posterminalIds = cat.posterminalIds.filter((id) => id !== terminalId);
}
},
setRenameMode(state, action) {
state.renameMode = Boolean(action.payload);
},
removeArticleBySku(state, action) {
const sku = action.payload;
state.articles = state.articles.filter((row) => row.sku !== sku);
state.articlesTotal = state.articles.length;
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
},
/** Keep category tile list in sync with article detail (e.g. after photo upload or category change). */
patchArticleInLists(state, action) {
const { sku, categoryIds, ...fields } = action.payload;
if (sku == null) return;
// Merge fields into existing article in both lists
const patchList = (list) =>
list.map((row) => {
if (row.sku !== sku) return row;
return { ...row, ...fields };
});
state.articles = patchList(state.articles);
state.rootArticles = patchList(state.rootArticles);
if (categoryIds === undefined) return;
const newCategoryIds = categoryIds ?? [];
// Helper: find the article object in either list
const findArticle = (list) => list.find((row) => row.sku === sku);
const articleObj = findArticle(state.articles) || findArticle(state.rootArticles);
if (!articleObj) return;
// Handle root category membership
if (state.rootCategoryId != null) {
const inRootCategory = newCategoryIds.some(
(id) => String(id) === String(state.rootCategoryId),
);
const alreadyInRoot = state.rootArticles.some((row) => row.sku === sku);
if (!inRootCategory) {
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
} else if (!alreadyInRoot) {
state.rootArticles = [...state.rootArticles, articleObj];
}
}
// Handle current category membership
if (state.currentCategoryId != null) {
const inCurrentCategory = newCategoryIds.some(
(id) => String(id) === String(state.currentCategoryId),
);
const alreadyInCurrent = state.articles.some((row) => row.sku === sku);
if (!inCurrentCategory) {
state.articles = state.articles.filter((row) => row.sku !== sku);
state.articlesTotal = state.articles.length;
} else if (!alreadyInCurrent) {
state.articles = [...state.articles, articleObj];
state.articlesTotal = state.articles.length;
}
}
},
},
extraReducers: (builder) => {
builder
.addCase(loadCategoryTree.pending, (state) => {
state.treeStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
state.languageId = action.payload.languageId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
})
.addCase(loadCategoryTree.rejected, (state, action) => {
state.treeStatus = 'failed';
state.error = resolveBackendError(action);
})
.addCase(loadCategoryArticles.pending, (state) => {
state.articlesStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
state.articles = action.payload.articles;
state.articlesTotal = action.payload.articles.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
state.articles = [];
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
.addCase(createCategory.fulfilled, (state, action) => {
if (!state.categories) state.categories = {};
state.categories[String(action.payload.id)] = action.payload;
})
.addCase(createCategories.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
if (!state.categories) state.categories = {};
for (const item of createdList) {
if (item == null || item.id == null) continue;
state.categories[String(item.id)] = item;
}
})
.addCase(renameCategory.fulfilled, (state, action) => {
const { id, name } = action.payload;
if (id == null || name == null) return;
const key = String(id);
const existing = state.categories?.[key];
if (existing) {
state.categories[key] = { ...existing, name };
} else if (state.categories) {
state.categories[key] = action.payload;
}
state.rootArticles = state.rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name }
: row,
);
})
.addCase(reorderCategories.fulfilled, (state, action) => {
const { parentId, categoryIds } = action.payload;
if (!state.categories) return;
const parentKey = String(parentId);
const idToSort = new Map(
categoryIds.map((id, index) => [String(id), index]),
);
for (const node of Object.values(state.categories)) {
if (
node.parentId != null &&
String(node.parentId) === parentKey &&
idToSort.has(String(node.id))
) {
state.categories[String(node.id)] = {
...node,
sort: idToSort.get(String(node.id)),
};
}
}
})
.addCase(moveCategory.fulfilled, (state, action) => {
const moved = action.payload;
if (moved == null || state.categories == null) return;
const existing = state.categories[String(moved.id)] ?? {};
// Der Move-Response enthält nur { id, parentId, sort } — keinen Namen.
// parentId/sort werden übernommen, der bestehende Name bleibt erhalten
// (das Backend hat ihn beim Move nicht verändert).
state.categories[String(moved.id)] = {
...existing,
parentId: moved.parentId,
sort: moved.sort,
};
})
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;
if (String(created.parentId) === String(state.currentCategoryId)) {
state.articles = [...state.articles, created];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(state.rootCategoryId)) {
state.rootArticles = [...state.rootArticles, created];
}
})
.addCase(createArticles.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
const added = createdList.filter((item) => item != null);
if (added.length === 0) return;
if (added.some((item) => String(item.parentId) === String(state.currentCategoryId))) {
state.articles = [
...state.articles,
...added.filter((item) =>
String(item.parentId) === String(state.currentCategoryId),
),
];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
const rootAdded = added.filter((item) =>
String(item.parentId) === String(state.rootCategoryId),
);
if (rootAdded.length > 0) {
state.rootArticles = [...state.rootArticles, ...rootAdded];
}
})
.addCase(deleteCategory.fulfilled, (state, action) => {
if (!state.categories) return;
delete state.categories[String(action.payload)];
if (String(state.currentCategoryId) === String(action.payload)) {
state.currentCategoryId = state.rootCategoryId;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
}
})
.addCase(toggleCategoryPos.fulfilled, (state, action) => {
const updated = action.payload;
if (updated == null || state.categories == null) return;
const key = String(updated.id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, pos: updated.pos };
}
})
.addCase(uploadCategoryImage.fulfilled, (state, action) => {
const { id, fullHash, thumbW, thumbH, thumbHash } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, fullHash, thumbW, thumbH, thumbHash };
}
})
.addCase(deleteCategoryImage.fulfilled, (state, action) => {
const { id } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = {
...existing,
fullHash: null,
thumbW: null,
thumbH: null,
thumbHash: null,
};
}
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return {
...initialState,
companyId: nextCompanyId,
};
}
})
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const {
navigateToCategory,
navigateBack,
resetCategoryState,
setRenameMode,
removeArticleBySku,
patchArticleInLists,
patchCategoryPosterminalIds,
} = categorySlice.actions;
export const selectRenameMode = (state) => state.category.renameMode;
export const selectCategoryMap = (state) => state.category.categories;
export const selectCurrentCategoryId = (state) => state.category.currentCategoryId;
export const selectCategoryRootId = (state) => state.category.rootCategoryId;
export const selectCategoryTreeStatus = (state) => state.category.treeStatus;
export const selectCategoryArticles = (state) => state.category.articles;
export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus;
export const selectCategoryError = (state) => state.category.error;
export const selectCurrentCategory = (state) => {
const id = state.category.currentCategoryId;
if (id == null) return null;
return state.category.categories?.[String(id)] ?? null;
};
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
export const selectCategoryBreadcrumbs = (state) =>
getCategoryBreadcrumbs(
state.category.categories,
state.category.currentCategoryId,
state.category.rootCategoryId,
);
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;
if (String(currentCategoryId) === String(rootCategoryId)) return false;
const parentId = categories?.[String(currentCategoryId)]?.parentId;
return parentId != null && parentId !== 0;
};
export default categorySlice.reducer;

View File

@@ -1,98 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { normalizeCompany } from '../utils/companyForm';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async () => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
},
);
export const updateCompany = createAsyncThunk(
'company/update',
async (payload) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
return normalizeCompany(data.company, payload);
},
);
const initialState = {
current: null,
status: 'idle',
error: null,
};
const companySlice = createSlice({
name: 'company',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadCurrentCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(loadCurrentCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(updateCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(updateCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(updateCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(AUTH_LOGGED_OUT, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
});
},
});
export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name;
export const selectCompanyIso639 = (state) => state.company.current?.iso639;
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
export function companyLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadCurrentCompany());
}
},
});
}
export default companySlice.reducer;

View File

@@ -1,55 +0,0 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import { setAuthEventDispatcher } from '../automats/authAutomat';
import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
import unitReducer, { unitLoadListener } from './unitSlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
function reduxDevLogger() {
return (storeApi) => (next) => (action) => {
if (
!import.meta.env.DEV ||
(!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') &&
!action.type?.startsWith('category/') &&
!action.type?.startsWith('brand/') &&
!action.type?.startsWith('posterminal/') &&
!action.type?.startsWith('unit/'))
) {
return next(action);
}
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
company: after.company.current,
brands: after.brand.brands.length,
posterminals: after.posterminal.terminals.length,
units: after.unit.units.length,
});
return result;
};
}
export const store = configureStore({
reducer: {
company: companyReducer,
category: categoryReducer,
brand: brandReducer,
posterminal: posterminalReducer,
unit: unitReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});
setAuthEventDispatcher(store.dispatch);

View File

@@ -1,267 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { posterminal } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { loadCurrentCompany } from './companySlice';
/**
* Load all posterminals for the selected company.
* GET /api/posterminal → { posterminals: [...] }
*/
export const loadPosterminals = createAsyncThunk(
'posterminal/load',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
requireDefined(companyId, 'company.id');
const data = await posterminal.list();
requireObject(data, 'GET /api/posterminal');
const list = requireArray(data.posterminals, 'posterminals');
return { companyId, terminals: list };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create a new posterminal.
* PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } }
*/
export const createPosterminal = createAsyncThunk(
'posterminal/create',
async ({ name }, { rejectWithValue }) => {
try {
const data = await posterminal.create({ name });
requireObject(data, 'PUT /api/posterminal');
requireObject(data.posterminal, 'posterminal');
requireDefined(data.posterminal.id, 'posterminal.id');
return data.posterminal;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Delete a posterminal.
* DELETE /api/posterminal/:id → 204
*/
export const deletePosterminal = createAsyncThunk(
'posterminal/delete',
async ({ terminalId }, { rejectWithValue }) => {
try {
await posterminal.delete(terminalId);
return terminalId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable all categories on a terminal.
* PUT /api/posterminal/:id/all → { id, categoryIds }
*/
export const enableAllTerminalCategories = createAsyncThunk(
'posterminal/enableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableAllCategories(terminalId);
requireObject(data, 'PUT /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable all categories on a terminal.
* DELETE /api/posterminal/:id/all → { id, categoryIds }
*/
export const disableAllTerminalCategories = createAsyncThunk(
'posterminal/disableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableAllCategories(terminalId);
requireObject(data, 'DELETE /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable a specific category on a terminal.
* PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const enableTerminalCategory = createAsyncThunk(
'posterminal/enableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableCategory(terminalId, categoryId);
requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable a specific category on a terminal.
* DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const disableTerminalCategory = createAsyncThunk(
'posterminal/disableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableCategory(terminalId, categoryId);
requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
terminals: [],
status: 'idle',
error: null,
};
const posterminalSlice = createSlice({
name: 'posterminal',
initialState,
reducers: {
resetPosterminalState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
// Load
.addCase(loadPosterminals.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadPosterminals.fulfilled, (state, action) => {
state.status = 'succeeded';
state.companyId = action.payload.companyId;
state.terminals = action.payload.terminals;
})
.addCase(loadPosterminals.rejected, (state, action) => {
state.status = 'failed';
state.error = resolveBackendError(action);
})
// Create
.addCase(createPosterminal.pending, (state) => {
state.error = null;
})
.addCase(createPosterminal.fulfilled, (state, action) => {
state.terminals.push(action.payload);
})
.addCase(createPosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Delete
.addCase(deletePosterminal.fulfilled, (state, action) => {
state.terminals = state.terminals.filter(
(t) => t.id !== action.payload,
);
})
.addCase(deletePosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable all categories
.addCase(enableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable all categories
.addCase(disableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable single category
.addCase(enableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable single category
.addCase(disableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Reset on company change
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return { ...initialState, companyId: nextCompanyId, status: 'idle' };
}
})
// Reset on logout
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const { resetPosterminalState } = posterminalSlice.actions;
/**
* Auto-load posterminals when the session/company is established.
* Mirrors brandLoadListener / unitLoadListener pattern.
*/
export function posterminalLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
console.log('[posterminalLoadListener]', action.type, { companyId });
if (companyId) {
api.dispatch(loadPosterminals());
}
},
});
}
// Selectors
export const selectPosterminals = (state) => state.posterminal.terminals;
export const selectPosterminalStatus = (state) => state.posterminal.status;
export const selectPosterminalError = (state) => state.posterminal.error;
export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0;
export default posterminalSlice.reducer;

View File

@@ -1,92 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { unit as unitApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
export const loadUnits = createAsyncThunk('unit/load', async () => {
const res = await unitApi.list();
requireObject(res, 'GET /api/unit');
return requireArray(res.units, 'units');
});
export const upsertUnit = createAsyncThunk('unit/upsert', async (body) => {
const res = await unitApi.upsert(body);
requireObject(res, 'PUT /api/unit');
requireObject(res.unit, 'unit');
return res.unit;
});
export const deleteUnit = createAsyncThunk('unit/delete', async (si) => {
await unitApi.delete(si);
return si;
});
const initialState = {
units: [],
status: 'idle',
error: null,
};
const unitSlice = createSlice({
name: 'unit',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadUnits.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadUnits.fulfilled, (state, action) => {
state.status = 'succeeded';
state.units = sortUnits(action.payload);
})
.addCase(loadUnits.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertUnit.fulfilled, (state, action) => {
const row = action.payload;
state.units = sortUnits([
...state.units.filter((u) => u.si !== row.si),
row,
]);
})
.addCase(deleteUnit.fulfilled, (state, action) => {
state.units = state.units.filter((u) => u.si !== action.payload);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectUnits = (state) => state.unit.units;
export const selectUnitsStatus = (state) => state.unit.status;
export const selectUnitsError = (state) => state.unit.error;
export function unitLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadUnits());
}
},
});
}
export default unitSlice.reducer;

111
src/utils/categoryUtils.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* Pure category tree helper functions.
* These operate on plain category maps and have no side-effects.
*/
export const ROOT_CATEGORY_NAME = 'Home';
/**
* @param {object} item
* @returns {object}
*/
export function normalizeCategoryNode(item) {
return {
...item,
id: item.id,
parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null,
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
};
}
/**
* Parse the raw GET /api/category/tree response body into normalized state.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
const list = Array.isArray(data.tree) ? data.tree : [];
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
return {
rootCategoryId,
categories,
rootArticles: Array.isArray(data.articles) ? data.articles : [],
languageId: data.languageId,
};
}
/**
* Display name for a category node (root → "Home").
*/
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
/**
* URL slug for a category node (root → "Home").
*/
export function categorySlug(node, rootCategoryId) {
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return node.slug || null;
}
/**
* Direct children of `parentId`, sorted by `sort` then `id`.
*/
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
/**
* Breadcrumb trail from root to `currentCategoryId`.
*/
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
if (!categories || currentCategoryId == null) return [];
const crumbs = [];
const seen = new Set();
let id = currentCategoryId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = categories[String(id)];
if (!node) break;
crumbs.unshift({
id: node.id,
name: categoryDisplayName(node.name, node.id, rootCategoryId),
slug: categorySlug(node, rootCategoryId),
});
const parentId = node.parentId;
if (parentId == null || parentId === 0) break;
if (String(parentId) === String(id)) break;
id = parentId;
}
return crumbs;
}
/**
* Map of category.id → category node from a raw tree response.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}

144
src/utils/textThumbnail.js Normal file
View File

@@ -0,0 +1,144 @@
let encodeAvif = null;
async function getAvifEncoder() {
if (!encodeAvif) {
const { default: encode } = await import('@jsquash/avif/encode');
encodeAvif = encode;
}
return encodeAvif;
}
/**
* Converts an ArrayBuffer to a base64 string safely without stack overflow.
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunkSize),
);
}
return btoa(binary);
}
/**
* Renders text onto a 2D canvas styled like a document page thumbnail.
* @param {string} text
* @param {number} [width=200]
* @param {number} [height=280]
* @returns {HTMLCanvasElement}
*/
export function renderTextToCanvas(text = '', width = 200, height = 280) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
// Background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Subtle border
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
// Top header accent line
ctx.fillStyle = '#cbd5e1';
ctx.fillRect(10, 8, 30, 2);
// Render text lines
ctx.fillStyle = '#334155';
ctx.font = '8px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const paddingX = 10;
const startY = 22;
const lineHeight = 11;
const maxLines = Math.floor((height - startY - 8) / lineHeight);
const maxWidth = width - paddingX * 2;
const rawLines = String(text || '').split(/\r?\n/);
const formattedLines = [];
for (const line of rawLines) {
if (formattedLines.length >= maxLines) break;
const clean = line.replace(/\t/g, ' ');
if (!clean.trim()) {
formattedLines.push('');
continue;
}
// Wrap or clip long lines
let current = '';
for (let i = 0; i < clean.length; i++) {
const candidate = current + clean[i];
if (ctx.measureText(candidate).width > maxWidth) {
formattedLines.push(current);
current = clean[i];
if (formattedLines.length >= maxLines) break;
} else {
current = candidate;
}
}
if (current && formattedLines.length < maxLines) {
formattedLines.push(current);
}
}
for (let idx = 0; idx < formattedLines.length; idx++) {
const y = startY + idx * lineHeight;
ctx.fillText(formattedLines[idx], paddingX, y);
}
return canvas;
}
/**
* Generates an AVIF thumbnail data URL for text content.
* @param {string|File|Blob} textSource
* @param {{ width?: number, height?: number, quality?: number }} [options]
* @returns {Promise<string|null>} Base64 Data URL (data:image/avif;base64,... or data:image/png;base64,...)
*/
export async function generateTextThumbnail(textSource, { width = 200, height = 280, quality = 70 } = {}) {
let text = '';
if (textSource instanceof Blob) {
text = await textSource.text();
} else if (typeof textSource === 'string') {
text = textSource;
} else if (textSource != null) {
text = String(textSource);
}
if (typeof document === 'undefined') {
return null;
}
const canvas = renderTextToCanvas(text, width, height);
const ctx = canvas.getContext('2d');
if (!ctx) return null;
try {
const imageData = ctx.getImageData(0, 0, width, height);
const encoder = await getAvifEncoder();
const avifBuffer = await encoder(imageData, {
quality,
speed: 9,
});
const base64 = arrayBufferToBase64(avifBuffer);
return `data:image/avif;base64,${base64}`;
} catch (err) {
console.warn('generateTextThumbnail: @jsquash/avif encode failed, falling back to PNG:', err);
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
}