no redux
This commit is contained in:
466
NGINX.md
Normal file
466
NGINX.md
Normal 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. |
|
||||||
@@ -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
|
To test the full PWA precache (production build + preview, no HMR), use
|
||||||
`npm run dev:pwa` instead.
|
`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
|
Dashback must be running and your Google account must be authorized on the
|
||||||
backend.
|
backend.
|
||||||
|
|
||||||
|
|||||||
168
deploy/nginx-dashfront.conf
Normal file
168
deploy/nginx-dashfront.conf
Normal 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
628
package-lock.json
generated
@@ -14,16 +14,14 @@
|
|||||||
"@jsquash/resize": "^2.1.1",
|
"@jsquash/resize": "^2.1.1",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.4.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.4.0",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
|
||||||
"@shopify/draggable": "^1.2.1",
|
"@shopify/draggable": "^1.2.1",
|
||||||
"dashgui": "file:../dashgui",
|
"dashgui": "file:../dashgui",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.11.0",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-automat": "^0.1.0",
|
"react-automat": "file:../automat",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-redux": "^9.3.0",
|
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
@@ -35,6 +33,25 @@
|
|||||||
"vite-plugin-pwa": "^1.3.0"
|
"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": {
|
"../dashgui": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -57,23 +74,6 @@
|
|||||||
"react-dom": "^19.0.0"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -2317,32 +2317,6 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
|
||||||
"integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
|
"integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2758,9 +2732,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
|
||||||
"integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
|
"integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2772,9 +2746,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
|
||||||
"integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
|
"integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2786,9 +2760,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
|
||||||
"integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
|
"integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2800,9 +2774,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
|
||||||
"integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
|
"integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2814,9 +2788,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
|
||||||
"integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
|
"integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2828,9 +2802,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
|
||||||
"integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
|
"integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2845,9 +2819,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
|
||||||
"integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
|
"integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2862,9 +2836,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
|
"integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2879,9 +2853,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
|
||||||
"integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
|
"integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2896,9 +2870,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
|
"integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -2913,9 +2887,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
|
||||||
"integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
|
"integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -2930,9 +2904,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
|
"integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2947,9 +2921,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
|
||||||
"integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
|
"integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2964,9 +2938,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
|
"integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2981,9 +2955,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
|
||||||
"integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
|
"integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2998,9 +2972,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
|
"integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -3015,9 +2989,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
|
"integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3032,9 +3006,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
|
||||||
"integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
|
"integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3049,9 +3023,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
|
||||||
"integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
|
"integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3063,9 +3037,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
|
||||||
"integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
|
"integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3077,9 +3051,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
|
||||||
"integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
|
"integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3091,9 +3065,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
|
||||||
"integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
|
"integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -3105,9 +3079,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
|
||||||
"integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
|
"integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3119,9 +3093,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
|
||||||
"integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
|
"integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3138,18 +3112,6 @@
|
|||||||
"integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==",
|
"integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||||
"version": "3.0.0-pre1",
|
"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",
|
"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,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
@@ -3287,16 +3243,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^0.4.1",
|
||||||
"require-from-string": "^2.0.2"
|
"uri-js": "^4.2.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3991,9 +3946,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.433",
|
"version": "1.5.434",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.434.tgz",
|
||||||
"integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==",
|
"integrity": "sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -4280,89 +4235,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/espree": {
|
||||||
"version": "11.2.0",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||||
@@ -4551,17 +4423,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "4.1.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^5.0.0",
|
"locate-path": "^6.0.0",
|
||||||
"path-exists": "^4.0.0"
|
"path-exists": "^4.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
@@ -4997,16 +4871,6 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -5570,10 +5434,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
@@ -5930,16 +5793,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^4.1.0"
|
"p-locate": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
@@ -6178,32 +6043,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "2.3.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-try": "^2.0.0"
|
"yocto-queue": "^0.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/p-locate": {
|
"node_modules/p-locate": {
|
||||||
"version": "4.1.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^2.2.0"
|
"p-limit": "^3.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/p-try": {
|
"node_modules/p-try": {
|
||||||
@@ -6481,18 +6347,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-automat": {
|
"node_modules/react-automat": {
|
||||||
"version": "0.1.0",
|
"resolved": "../automat",
|
||||||
"resolved": "https://registry.npmjs.org/react-automat/-/react-automat-0.1.0.tgz",
|
"link": true
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.3.0",
|
"version": "19.3.0",
|
||||||
@@ -6512,29 +6368,6 @@
|
|||||||
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
|
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.4",
|
"version": "7.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||||
@@ -6589,21 +6422,6 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6733,12 +6551,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -6804,9 +6616,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.3",
|
"version": "4.63.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
||||||
"integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
|
"integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6821,31 +6633,31 @@
|
|||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
||||||
"@rollup/rollup-android-arm-eabi": "4.63.3",
|
"@rollup/rollup-android-arm-eabi": "4.63.4",
|
||||||
"@rollup/rollup-android-arm64": "4.63.3",
|
"@rollup/rollup-android-arm64": "4.63.4",
|
||||||
"@rollup/rollup-darwin-arm64": "4.63.3",
|
"@rollup/rollup-darwin-arm64": "4.63.4",
|
||||||
"@rollup/rollup-darwin-x64": "4.63.3",
|
"@rollup/rollup-darwin-x64": "4.63.4",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.63.3",
|
"@rollup/rollup-freebsd-arm64": "4.63.4",
|
||||||
"@rollup/rollup-freebsd-x64": "4.63.3",
|
"@rollup/rollup-freebsd-x64": "4.63.4",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.63.3",
|
"@rollup/rollup-linux-arm-musleabihf": "4.63.4",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.63.3",
|
"@rollup/rollup-linux-arm64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.63.3",
|
"@rollup/rollup-linux-arm64-musl": "4.63.4",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.63.3",
|
"@rollup/rollup-linux-loong64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.63.3",
|
"@rollup/rollup-linux-loong64-musl": "4.63.4",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.63.3",
|
"@rollup/rollup-linux-ppc64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.63.3",
|
"@rollup/rollup-linux-ppc64-musl": "4.63.4",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.63.3",
|
"@rollup/rollup-linux-riscv64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.63.3",
|
"@rollup/rollup-linux-riscv64-musl": "4.63.4",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.63.3",
|
"@rollup/rollup-linux-s390x-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.63.3",
|
"@rollup/rollup-linux-x64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.63.3",
|
"@rollup/rollup-linux-x64-musl": "4.63.4",
|
||||||
"@rollup/rollup-openbsd-x64": "4.63.3",
|
"@rollup/rollup-openbsd-x64": "4.63.4",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.63.3",
|
"@rollup/rollup-openharmony-arm64": "4.63.4",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.63.3",
|
"@rollup/rollup-win32-arm64-msvc": "4.63.4",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.63.3",
|
"@rollup/rollup-win32-ia32-msvc": "4.63.4",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.63.3",
|
"@rollup/rollup-win32-x64-gnu": "4.63.4",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.63.3",
|
"@rollup/rollup-win32-x64-msvc": "4.63.4",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7641,15 +7453,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
@@ -7955,6 +7758,47 @@
|
|||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/workbox-build/node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
@@ -8181,6 +8025,62 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -20,16 +20,14 @@
|
|||||||
"@jsquash/resize": "^2.1.1",
|
"@jsquash/resize": "^2.1.1",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.4.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.4.0",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
|
||||||
"@shopify/draggable": "^1.2.1",
|
"@shopify/draggable": "^1.2.1",
|
||||||
"dashgui": "file:../dashgui",
|
"dashgui": "file:../dashgui",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.11.0",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-automat": "^0.1.0",
|
"react-automat": "file:../automat",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-redux": "^9.3.0",
|
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
import { parseCategoryTreeResponse } from '../store/categorySlice';
|
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
|
||||||
import { ApiClient } from './ApiClient';
|
import { ApiClient } from './ApiClient';
|
||||||
import artikelAutomat from '../automats/artikelAutomat';
|
import artikelAutomat from '../automats/artikelAutomat';
|
||||||
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import 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()
|
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||||
@@ -172,20 +178,25 @@ export class StorageManager extends ApiClient {
|
|||||||
* @returns {{ categories: object, rootCategoryId: unknown } | null}
|
* @returns {{ categories: object, rootCategoryId: unknown } | null}
|
||||||
*/
|
*/
|
||||||
getCategoryState() {
|
getCategoryState() {
|
||||||
const treeData = this.categoryTree;
|
const company = this.company;
|
||||||
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);
|
|
||||||
if (company == null) return null;
|
if (company == null) return null;
|
||||||
|
|
||||||
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
|
const treeData = this.categoryTree;
|
||||||
|
if (treeData != null) {
|
||||||
|
// Legacy: tree was loaded via the old cache mechanism
|
||||||
|
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
|
||||||
|
return {
|
||||||
|
categories: parsed.categories,
|
||||||
|
rootCategoryId: parsed.rootCategoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// New path: use categoryAutomat state directly
|
||||||
|
const catState = categoryAutomat.state;
|
||||||
|
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
||||||
return {
|
return {
|
||||||
categories: parsed.categories,
|
categories: catState.categories,
|
||||||
rootCategoryId: parsed.rootCategoryId,
|
rootCategoryId: catState.rootCategoryId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,13 +386,6 @@ export class StorageManager extends ApiClient {
|
|||||||
|
|
||||||
#initCaches() {
|
#initCaches() {
|
||||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
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 deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
|
||||||
const customergroupCache = createCache('GET /customergroup', () =>
|
const customergroupCache = createCache('GET /customergroup', () =>
|
||||||
this.#loadCustomergroups(),
|
this.#loadCustomergroups(),
|
||||||
@@ -391,6 +395,19 @@ export class StorageManager extends ApiClient {
|
|||||||
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
|
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
|
||||||
const orderCache = createCache('GET /order', () => this.#loadOrders());
|
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, {
|
Object.defineProperties(this, {
|
||||||
authMePromise: { get: () => authMeCache.promise },
|
authMePromise: { get: () => authMeCache.promise },
|
||||||
updateAuthMe: { value: authMeCache.update },
|
updateAuthMe: { value: authMeCache.update },
|
||||||
@@ -400,29 +417,68 @@ export class StorageManager extends ApiClient {
|
|||||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
articleIndexPromise: { get: () => articleIndexCache.promise },
|
||||||
updateArticleIndex: { value: articleIndexCache.update },
|
updateArticleIndex: { value: articleIndexCache.update },
|
||||||
|
|
||||||
brandPromise: { get: () => brandCache.promise },
|
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
|
||||||
updateBrand: { value: brandCache.update },
|
/** Brand list from brandAutomat (may be empty until authed). */
|
||||||
/** Settled GET /brand list, or undefined. */
|
brands: { get: () => brandAutomat.state.brands },
|
||||||
brands: { get: () => brandCache.value },
|
/** 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 },
|
/** Unit list from unitAutomat. */
|
||||||
updateUnit: { value: unitCache.update },
|
units: { get: () => unitAutomat.state.units },
|
||||||
/** Settled GET /unit list, or undefined. */
|
/** Promise that resolves when units are first loaded. */
|
||||||
units: { get: () => unitCache.value },
|
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 },
|
* Current company object from companyAutomat, or null.
|
||||||
/** Settled GET /company list, or undefined. */
|
* Previously: companies array from GET /api/company.
|
||||||
companies: { get: () => companyCache.value },
|
* 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 },
|
categoryTreePromise: { get: () => categoryTreeCache.promise },
|
||||||
updateCategoryTree: { value: categoryTreeCache.update },
|
updateCategoryTree: { value: categoryTreeCache.update },
|
||||||
/** Settled GET /category/tree body, or undefined. */
|
/** Settled GET /category/tree body, or undefined. */
|
||||||
categoryTree: { get: () => categoryTreeCache.value },
|
categoryTree: { get: () => categoryTreeCache.value },
|
||||||
|
|
||||||
posterminalPromise: { get: () => posterminalCache.promise },
|
// ── Remaining non-auth-gated caches ─────────────────────────────────────
|
||||||
updatePosterminal: { value: posterminalCache.update },
|
|
||||||
|
|
||||||
deliveryPromise: { get: () => deliveryCache.promise },
|
deliveryPromise: { get: () => deliveryCache.promise },
|
||||||
updateDelivery: { value: deliveryCache.update },
|
updateDelivery: { value: deliveryCache.update },
|
||||||
/** Settled GET /delivery list, or undefined. */
|
/** Settled GET /delivery list, or undefined. */
|
||||||
@@ -456,8 +512,63 @@ export class StorageManager extends ApiClient {
|
|||||||
/** Close and reopen GET /api/events (required after selected-company change). */
|
/** Close and reopen GET /api/events (required after selected-company change). */
|
||||||
reconnectEvents: { value: () => this.#reconnectEvents() },
|
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.
|
* GET /api/events — SSE signals for the selected company.
|
||||||
* Stream is bound at connect time; reconnect after company switch.
|
* Stream is bound at connect time; reconnect after company switch.
|
||||||
|
|||||||
81
src/automats/brandAutomat.js
Normal file
81
src/automats/brandAutomat.js
Normal 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;
|
||||||
490
src/automats/categoryAutomat.js
Normal file
490
src/automats/categoryAutomat.js
Normal 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;
|
||||||
96
src/automats/companyAutomat.js
Normal file
96
src/automats/companyAutomat.js
Normal 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;
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
|
|
||||||
export { default as authAutomat } from './authAutomat';
|
export { default as authAutomat } from './authAutomat';
|
||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
|
|
||||||
export { default as navigationAutomat } from './navigationAutomat';
|
export { default as navigationAutomat } from './navigationAutomat';
|
||||||
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
||||||
|
|
||||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
||||||
export { setViewerPosition } from './userSettingsAutomat';
|
export { setViewerPosition } from './userSettingsAutomat';
|
||||||
|
|
||||||
export { default as supplierAutomat } from './supplierAutomat';
|
export { default as supplierAutomat } from './supplierAutomat';
|
||||||
|
|
||||||
export { default as artikelAutomat } from './artikelAutomat';
|
export { default as artikelAutomat } from './artikelAutomat';
|
||||||
export {
|
export {
|
||||||
getArticle,
|
getArticle,
|
||||||
@@ -13,3 +18,37 @@ export {
|
|||||||
selectArticle,
|
selectArticle,
|
||||||
createArticleSelector,
|
createArticleSelector,
|
||||||
} from './artikelAutomat';
|
} 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';
|
||||||
|
|||||||
142
src/automats/posterminalAutomat.js
Normal file
142
src/automats/posterminalAutomat.js
Normal 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;
|
||||||
94
src/automats/unitAutomat.js
Normal file
94
src/automats/unitAutomat.js
Normal 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;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
import MenuList from '@mui/material/MenuList';
|
import MenuList from '@mui/material/MenuList';
|
||||||
@@ -14,13 +13,7 @@ import { enqueueSnackbar } from 'notistack';
|
|||||||
import PageLayout from './PageLayout';
|
import PageLayout from './PageLayout';
|
||||||
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
||||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||||
import {
|
import categoryAutomat from '../automats/categoryAutomat';
|
||||||
selectRenameMode,
|
|
||||||
setRenameMode,
|
|
||||||
selectCategoryMap,
|
|
||||||
selectCategoryRootId,
|
|
||||||
loadCategoryTree,
|
|
||||||
} from '../store/categorySlice';
|
|
||||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||||
import { article, category } from '../api';
|
import { article, category } from '../api';
|
||||||
import { importArtikelCsv } from '../utils/importArtikel';
|
import { importArtikelCsv } from '../utils/importArtikel';
|
||||||
@@ -37,15 +30,31 @@ function triggerDownload(blob, filename) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectCatSlice(s) {
|
||||||
|
return {
|
||||||
|
renameMode: s.renameMode,
|
||||||
|
categories: s.categories,
|
||||||
|
rootCategoryId: s.rootCategoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function SectionLayout({ sectionPath }) {
|
export default function SectionLayout({ sectionPath }) {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const dispatch = useDispatch();
|
|
||||||
const renameMode = useSelector(selectRenameMode);
|
|
||||||
const categories = useSelector(selectCategoryMap);
|
|
||||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
|
||||||
const importInputRef = useRef(null);
|
const importInputRef = useRef(null);
|
||||||
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
||||||
const [importing, setImporting] = useState(false);
|
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 title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
||||||
const subNav = SUB_NAV[sectionPath];
|
const subNav = SUB_NAV[sectionPath];
|
||||||
const subSegment = resolveActiveSub(pathname, sectionPath);
|
const subSegment = resolveActiveSub(pathname, sectionPath);
|
||||||
@@ -109,7 +118,7 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.ok > 0 && rootCategoryId != null) {
|
if (result.ok > 0 && rootCategoryId != null) {
|
||||||
await dispatch(loadCategoryTree()).unwrap();
|
await categoryAutomat.actions.loadTree();
|
||||||
}
|
}
|
||||||
if (result.ok > 0) {
|
if (result.ok > 0) {
|
||||||
enqueueSnackbar(
|
enqueueSnackbar(
|
||||||
@@ -136,7 +145,7 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
<ToggleElevatedPill
|
<ToggleElevatedPill
|
||||||
label="Edit"
|
label="Edit"
|
||||||
checked={renameMode}
|
checked={renameMode}
|
||||||
onToggle={(checked) => dispatch(setRenameMode(checked))}
|
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
|
||||||
/>
|
/>
|
||||||
<ActionElevatedPill
|
<ActionElevatedPill
|
||||||
label="Extras"
|
label="Extras"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
|
|||||||
import FolderIcon from '@mui/icons-material/Folder';
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { getCategoryChildren, categoryDisplayName } from '../../store/categorySlice';
|
import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
|
||||||
function CategoryNode({
|
function CategoryNode({
|
||||||
categories,
|
categories,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { ActionTile } from '../tiles';
|
import { ActionTile } from '../tiles';
|
||||||
import {
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
createArticles as createArticlesThunk,
|
|
||||||
createArticle as createArticleThunk,
|
|
||||||
} from '../../store/categorySlice';
|
|
||||||
import ArticleNameDialog from './ArticleNameDialog';
|
import ArticleNameDialog from './ArticleNameDialog';
|
||||||
|
|
||||||
class NewArticleTile extends Component {
|
class NewArticleTile extends Component {
|
||||||
@@ -28,15 +24,16 @@ class NewArticleTile extends Component {
|
|||||||
const { parentId } = this.props;
|
const { parentId } = this.props;
|
||||||
const list = (names ?? []).filter(Boolean);
|
const list = (names ?? []).filter(Boolean);
|
||||||
if (list.length <= 1) {
|
if (list.length <= 1) {
|
||||||
const article = await this.props
|
const article = await categoryAutomat.actions.createArticle({
|
||||||
.createArticle({ parentId, name: list[0] ?? '' })
|
parentId,
|
||||||
.unwrap();
|
name: list[0] ?? '',
|
||||||
|
});
|
||||||
if (article) {
|
if (article) {
|
||||||
this.props.onOpenArticle?.(article);
|
this.props.onOpenArticle?.(article);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created = await this.props.createArticles({ parentId, names: list }).unwrap();
|
const created = await categoryAutomat.actions.createArticles({ parentId, names: list });
|
||||||
if (created?.[0]) {
|
if (created?.[0]) {
|
||||||
this.props.onOpenArticle?.(created[0]);
|
this.props.onOpenArticle?.(created[0]);
|
||||||
}
|
}
|
||||||
@@ -62,9 +59,4 @@ class NewArticleTile extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
export default NewArticleTile;
|
||||||
createArticle: createArticleThunk,
|
|
||||||
createArticles: createArticlesThunk,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(NewArticleTile);
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
|
|||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||||
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
|
import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
|
||||||
import { Box, Chip, IconButton } from '@mui/material';
|
import { Box, Chip, IconButton } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CategoryPickerDialog from '../CategoryPickerDialog';
|
import CategoryPickerDialog from '../CategoryPickerDialog';
|
||||||
|
|||||||
@@ -169,12 +169,7 @@ function priceDraftValid(draft) {
|
|||||||
|
|
||||||
function companyFromStore(store) {
|
function companyFromStore(store) {
|
||||||
if (!store) return null;
|
if (!store) return null;
|
||||||
const companies = store.companies;
|
return store.company ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PriceAmountField extends Component {
|
class PriceAmountField extends Component {
|
||||||
@@ -395,18 +390,12 @@ export default class ArticleViewPrice extends PureComponent {
|
|||||||
async load() {
|
async load() {
|
||||||
const store = this.context;
|
const store = this.context;
|
||||||
try {
|
try {
|
||||||
const [unitList, companies, session] = await Promise.all([
|
const [unitList, company] = await Promise.all([
|
||||||
store.unitPromise,
|
store.unitPromise,
|
||||||
store.companyPromise,
|
store.companyPromise,
|
||||||
store.authMePromise,
|
|
||||||
]);
|
]);
|
||||||
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
|
||||||
const current = requireDefined(
|
|
||||||
companies.find((c) => c.id === companyId),
|
|
||||||
'company',
|
|
||||||
);
|
|
||||||
if (!this._cancelled) {
|
if (!this._cancelled) {
|
||||||
this.setState({ units: unitList, company: current });
|
this.setState({ units: unitList, company: company ?? null });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this._cancelled) notifyError(err);
|
if (!this._cancelled) notifyError(err);
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { ActionTile } from '../tiles';
|
import { ActionTile } from '../tiles';
|
||||||
import {
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
createCategories as createCategoriesThunk,
|
|
||||||
createCategory as createCategoryThunk,
|
|
||||||
} from '../../store/categorySlice';
|
|
||||||
import CategoryNameDialog from './CategoryNameDialog';
|
import CategoryNameDialog from './CategoryNameDialog';
|
||||||
|
|
||||||
class NewCategoryTile extends Component {
|
class NewCategoryTile extends Component {
|
||||||
@@ -28,10 +24,10 @@ class NewCategoryTile extends Component {
|
|||||||
const { parentId } = this.props;
|
const { parentId } = this.props;
|
||||||
const list = (names ?? []).filter(Boolean);
|
const list = (names ?? []).filter(Boolean);
|
||||||
if (list.length <= 1) {
|
if (list.length <= 1) {
|
||||||
await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap();
|
await categoryAutomat.actions.createCategory({ parentId, name: list[0] ?? '' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.props.createCategories({ parentId, names: list }).unwrap();
|
await categoryAutomat.actions.createCategories({ parentId, names: list });
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -55,9 +51,4 @@ class NewCategoryTile extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
export default NewCategoryTile;
|
||||||
createCategory: createCategoryThunk,
|
|
||||||
createCategories: createCategoriesThunk,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(NewCategoryTile);
|
|
||||||
|
|||||||
@@ -8,8 +8,43 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
|||||||
import SyncIcon from '@mui/icons-material/Sync';
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||||
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||||
|
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||||
|
|
||||||
export class Attachment extends PureComponent {
|
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) => {
|
handleRescan = (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
const { attachment, onRescan } = this.props;
|
const { attachment, onRescan } = this.props;
|
||||||
@@ -56,7 +91,7 @@ export class Attachment extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { attachment, disabled, sx } = this.props;
|
const { attachment, disabled, sx } = this.props;
|
||||||
const thumbnail = attachment?.thumbnail;
|
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
|
||||||
const name = attachment?.name || 'Dokument';
|
const name = attachment?.name || 'Dokument';
|
||||||
const size = attachment?.size;
|
const size = attachment?.size;
|
||||||
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
|
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
|
||||||
|
|||||||
@@ -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({
|
files.push({
|
||||||
file: fileToUpload,
|
file: fileToUpload,
|
||||||
name: attachment.name || fileToUpload.name,
|
name: attachment.name || fileToUpload.name,
|
||||||
mimetype: attachment.type || fileToUpload.type,
|
mimetype: attachment.type || fileToUpload.type,
|
||||||
|
thumbavif,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -389,6 +397,10 @@ export async function buildManifestBody(
|
|||||||
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
|
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
|
||||||
if (filesToUpload.length > 0) {
|
if (filesToUpload.length > 0) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
body.filesMeta = filesToUpload.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
thumbavif: item.thumbavif ?? null,
|
||||||
|
}));
|
||||||
formData.append('data', JSON.stringify(body));
|
formData.append('data', JSON.stringify(body));
|
||||||
for (const item of filesToUpload) {
|
for (const item of filesToUpload) {
|
||||||
formData.append('files', item.file, item.name);
|
formData.append('files', item.file, item.name);
|
||||||
|
|||||||
52
src/main.jsx
52
src/main.jsx
@@ -3,12 +3,10 @@ import { lazy, Suspense } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { SnackbarProvider } from 'notistack';
|
import { SnackbarProvider } from 'notistack';
|
||||||
import AuthBootstrap from './components/AuthBootstrap';
|
import AuthBootstrap from './components/AuthBootstrap';
|
||||||
import { StorageManagerProvider } from './api/storageManagerContext';
|
import { StorageManagerProvider } from './api/storageManagerContext';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { store } from './store';
|
|
||||||
import { getActiveTheme } from './theme';
|
import { getActiveTheme } from './theme';
|
||||||
import './automats/supplierAutomat';
|
import './automats/supplierAutomat';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
@@ -34,31 +32,29 @@ if (import.meta.env.DEV) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<Provider store={store}>
|
<ThemeProvider theme={getActiveTheme()}>
|
||||||
<ThemeProvider theme={getActiveTheme()}>
|
<CssBaseline />
|
||||||
<CssBaseline />
|
<SnackbarProvider
|
||||||
<SnackbarProvider
|
maxSnack={3}
|
||||||
maxSnack={3}
|
autoHideDuration={2000}
|
||||||
autoHideDuration={2000}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
>
|
||||||
>
|
<StorageManagerProvider>
|
||||||
<StorageManagerProvider>
|
<AuthBootstrap>
|
||||||
<AuthBootstrap>
|
{/* Flex row so the dev inspector docks beside the app instead of
|
||||||
{/* Flex row so the dev inspector docks beside the app instead of
|
overlaying it; the app content shrinks while the panel is open. */}
|
||||||
overlaying it; the app content shrinks while the panel is open. */}
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
<App />
|
||||||
<App />
|
|
||||||
</Box>
|
|
||||||
{AutomatInspector ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AutomatInspector />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
</AuthBootstrap>
|
{AutomatInspector ? (
|
||||||
</StorageManagerProvider>
|
<Suspense fallback={null}>
|
||||||
</SnackbarProvider>
|
<AutomatInspector />
|
||||||
</ThemeProvider>
|
</Suspense>
|
||||||
</Provider>,
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</AuthBootstrap>
|
||||||
|
</StorageManagerProvider>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
categorySlug,
|
categorySlug,
|
||||||
getCategoryBreadcrumbs,
|
getCategoryBreadcrumbs,
|
||||||
getCategoryChildren,
|
getCategoryChildren,
|
||||||
} from '../../store/categorySlice';
|
} from '../../utils/categoryUtils';
|
||||||
|
|
||||||
export { ROOT_CATEGORY_NAME, categorySlug };
|
export { ROOT_CATEGORY_NAME, categorySlug };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, createRef } from 'react';
|
import { Component, PureComponent, createRef } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
@@ -19,41 +18,49 @@ import {
|
|||||||
import { TILE_SIZE } from 'dashgui/tiles';
|
import { TILE_SIZE } from 'dashgui/tiles';
|
||||||
import withCategoryRoute from './withCategoryRoute';
|
import withCategoryRoute from './withCategoryRoute';
|
||||||
import { artikelDetailPath, articleSku } from './articleRoute';
|
import { artikelDetailPath, articleSku } from './articleRoute';
|
||||||
import { selectCompanyId } from '../../store/companySlice';
|
|
||||||
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
|
|
||||||
import {
|
import {
|
||||||
buildCategoryPath,
|
buildCategoryPath,
|
||||||
defaultCategoryPath,
|
defaultCategoryPath,
|
||||||
resolveCategoryIdFromSlugPath,
|
resolveCategoryIdFromSlugPath,
|
||||||
ROOT_CATEGORY_NAME,
|
ROOT_CATEGORY_NAME,
|
||||||
} from './categoryRoute';
|
} from './categoryRoute';
|
||||||
import {
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
categoryDisplayName,
|
import posterminalAutomat from '../../automats/posterminalAutomat';
|
||||||
deleteCategory,
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
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 { setLastArticleSku } from '../../automats';
|
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 = {
|
const gridSx = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -61,10 +68,15 @@ const gridSx = {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
class ArtikelKategorienPage extends Component {
|
class ArtikelKategorienPage extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(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.gridRef = createRef();
|
||||||
this.sortableModule = null;
|
this.sortableModule = null;
|
||||||
this.droppableModule = null;
|
this.droppableModule = null;
|
||||||
@@ -74,6 +86,11 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
setLastArticleSku(null);
|
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.ensureTreeLoaded();
|
||||||
this.ensurePosterminalsLoaded();
|
this.ensurePosterminalsLoaded();
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
@@ -82,37 +99,36 @@ class ArtikelKategorienPage extends Component {
|
|||||||
this.syncDroppable();
|
this.syncDroppable();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevState) {
|
||||||
if (this.props.companyId !== prevProps.companyId) {
|
// Note: PureComponent — comparisons use this.state not this.props
|
||||||
|
if (this.state.companyId !== prevState.companyId) {
|
||||||
this.ensureTreeLoaded();
|
this.ensureTreeLoaded();
|
||||||
}
|
}
|
||||||
|
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
|
||||||
if (
|
|
||||||
this.props.treeStatus === 'succeeded' &&
|
|
||||||
prevProps.treeStatus !== 'succeeded'
|
|
||||||
) {
|
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
this.ensureArticlesLoaded();
|
this.ensureArticlesLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.props.treeStatus === 'succeeded' &&
|
this.state.treeStatus === 'succeeded' &&
|
||||||
this.props.categorySlugPath !== prevProps.categorySlugPath
|
this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
|
||||||
) {
|
) {
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
}
|
}
|
||||||
|
this._prevSlugPath = this.props.categorySlugPath;
|
||||||
|
|
||||||
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
|
if (this.state.currentCategoryId !== prevState.currentCategoryId) {
|
||||||
this.ensureArticlesLoaded();
|
this.ensureArticlesLoaded();
|
||||||
}
|
}
|
||||||
|
if (this.state.renameMode !== prevState.renameMode) {
|
||||||
if (this.props.renameMode !== prevProps.renameMode) {
|
|
||||||
this.syncSortable();
|
this.syncSortable();
|
||||||
this.syncDroppable();
|
this.syncDroppable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
this._unsubCat?.();
|
||||||
|
this._unsubTerm?.();
|
||||||
|
this._unsubCompany?.();
|
||||||
this.sortableModule?.destroy();
|
this.sortableModule?.destroy();
|
||||||
this.droppableModule?.destroy();
|
this.droppableModule?.destroy();
|
||||||
}
|
}
|
||||||
@@ -122,19 +138,21 @@ class ArtikelKategorienPage extends Component {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!this.sortableModule) {
|
if (!this.sortableModule) {
|
||||||
this.sortableModule = new CategorySortable(el, {
|
this.sortableModule = new CategorySortable(el, {
|
||||||
onReorder: this.props.reorderCategories,
|
onReorder: ({ categoryIds }) =>
|
||||||
|
categoryAutomat.actions.reorderCategories({ categoryIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!this.droppableModule) {
|
if (!this.droppableModule) {
|
||||||
this.droppableModule = new CategoryDroppable(el, {
|
this.droppableModule = new CategoryDroppable(el, {
|
||||||
onMove: this.props.moveCategory,
|
onMove: ({ categoryId, parentId }) =>
|
||||||
|
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncSortable = () => {
|
syncSortable = () => {
|
||||||
this.#ensureModules();
|
this.#ensureModules();
|
||||||
if (this.props.renameMode) {
|
if (this.state.renameMode) {
|
||||||
this.sortableModule?.destroy();
|
this.sortableModule?.destroy();
|
||||||
this.sortableModule?.activate();
|
this.sortableModule?.activate();
|
||||||
} else {
|
} else {
|
||||||
@@ -144,7 +162,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
syncDroppable = () => {
|
syncDroppable = () => {
|
||||||
this.#ensureModules();
|
this.#ensureModules();
|
||||||
if (this.props.renameMode) {
|
if (this.state.renameMode) {
|
||||||
this.droppableModule?.destroy();
|
this.droppableModule?.destroy();
|
||||||
this.droppableModule?.activate();
|
this.droppableModule?.activate();
|
||||||
} else {
|
} else {
|
||||||
@@ -157,33 +175,32 @@ class ArtikelKategorienPage extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ensureTreeLoaded = () => {
|
ensureTreeLoaded = () => {
|
||||||
const { companyId, treeStatus } = this.props;
|
const { companyId, treeStatus } = this.state;
|
||||||
if (!companyId || treeStatus !== 'idle') return;
|
if (!companyId || treeStatus !== 'idle') return;
|
||||||
this.props.loadCategoryTree();
|
void categoryAutomat.actions.loadTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
ensurePosterminalsLoaded = () => {
|
ensurePosterminalsLoaded = () => {
|
||||||
const { companyId } = this.props;
|
const { companyId } = this.state;
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
this.props.loadPosterminals();
|
void posterminalAutomat.actions.load();
|
||||||
};
|
};
|
||||||
|
|
||||||
ensureArticlesLoaded = () => {
|
ensureArticlesLoaded = () => {
|
||||||
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props;
|
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state;
|
||||||
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
|
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
|
||||||
if (String(currentCategoryId) === String(rootCategoryId)) return;
|
if (String(currentCategoryId) === String(rootCategoryId)) return;
|
||||||
this.props.loadCategoryArticles(currentCategoryId);
|
void categoryAutomat.actions.loadArticles(currentCategoryId);
|
||||||
};
|
};
|
||||||
|
|
||||||
syncCategoryFromUrl = () => {
|
syncCategoryFromUrl = () => {
|
||||||
const {
|
const {
|
||||||
categories,
|
categories,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
categorySlugPath,
|
|
||||||
currentCategoryId,
|
currentCategoryId,
|
||||||
treeStatus,
|
treeStatus,
|
||||||
navigate,
|
} = this.state;
|
||||||
} = this.props;
|
const { categorySlugPath, navigate } = this.props;
|
||||||
|
|
||||||
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
|
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
|
||||||
|
|
||||||
@@ -206,13 +223,13 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedId !== currentCategoryId) {
|
if (resolvedId !== currentCategoryId) {
|
||||||
this.props.navigateToCategory(resolvedId);
|
categoryAutomat.actions.navigateTo(resolvedId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
navigateToCategoryById = (categoryId) => {
|
navigateToCategoryById = (categoryId) => {
|
||||||
const { categories, rootCategoryId, navigate } = this.props;
|
const { categories, rootCategoryId } = this.state;
|
||||||
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDraftChange(categoryId, value) {
|
handleDraftChange(categoryId, value) {
|
||||||
@@ -228,10 +245,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.props.renameCategory({
|
await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
|
||||||
categoryId,
|
|
||||||
name: trimmed,
|
|
||||||
}).unwrap();
|
|
||||||
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
|
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -240,7 +254,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
handleDeleteCategory = async (category) => {
|
handleDeleteCategory = async (category) => {
|
||||||
try {
|
try {
|
||||||
await this.props.deleteCategory(category.id).unwrap();
|
await categoryAutomat.actions.deleteCategory(category.id);
|
||||||
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
|
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -248,18 +262,18 @@ class ArtikelKategorienPage extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleRetryTree = () => {
|
handleRetryTree = () => {
|
||||||
if (this.props.companyId) this.props.loadCategoryTree();
|
if (this.state.companyId) void categoryAutomat.actions.loadTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleRetryArticles = () => {
|
handleRetryArticles = () => {
|
||||||
const { currentCategoryId } = this.props;
|
const { currentCategoryId } = this.state;
|
||||||
if (currentCategoryId) {
|
if (currentCategoryId) {
|
||||||
this.props.loadCategoryArticles(currentCategoryId);
|
void categoryAutomat.actions.loadArticles(currentCategoryId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNavigateBack = () => {
|
handleNavigateBack = () => {
|
||||||
const { breadcrumbs } = this.props;
|
const { breadcrumbs } = this.state;
|
||||||
if (breadcrumbs.length < 2) return;
|
if (breadcrumbs.length < 2) return;
|
||||||
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
|
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
|
||||||
};
|
};
|
||||||
@@ -283,7 +297,8 @@ class ArtikelKategorienPage extends Component {
|
|||||||
articles,
|
articles,
|
||||||
renameMode,
|
renameMode,
|
||||||
terminals,
|
terminals,
|
||||||
} = this.props;
|
drafts,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
return (
|
return (
|
||||||
@@ -347,8 +362,8 @@ class ArtikelKategorienPage extends Component {
|
|||||||
{childCategories.map((item, index) => {
|
{childCategories.map((item, index) => {
|
||||||
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
||||||
const draftName =
|
const draftName =
|
||||||
this.state.drafts[item.id] !== undefined
|
drafts[item.id] !== undefined
|
||||||
? this.state.drafts[item.id]
|
? drafts[item.id]
|
||||||
: label;
|
: label;
|
||||||
const editProps = renameMode
|
const editProps = renameMode
|
||||||
? {
|
? {
|
||||||
@@ -359,13 +374,12 @@ class ArtikelKategorienPage extends Component {
|
|||||||
terminals,
|
terminals,
|
||||||
categoryPosterminalIds: item.posterminalIds || [],
|
categoryPosterminalIds: item.posterminalIds || [],
|
||||||
onTerminalToggle: (terminalId, enabled) => {
|
onTerminalToggle: (terminalId, enabled) => {
|
||||||
const thunk = enabled
|
const action = enabled
|
||||||
? this.props.enableTerminalCategory
|
? posterminalAutomat.actions.enableCategory
|
||||||
: this.props.disableTerminalCategory;
|
: posterminalAutomat.actions.disableCategory;
|
||||||
void thunk({ terminalId, categoryId: item.id })
|
void action({ terminalId, categoryId: item.id })
|
||||||
.unwrap()
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.props.patchCategoryPosterminalIds({
|
categoryAutomat.actions.patchCategoryPosterminalIds({
|
||||||
categoryId: item.id,
|
categoryId: item.id,
|
||||||
terminalId,
|
terminalId,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -382,10 +396,9 @@ class ArtikelKategorienPage extends Component {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
// Prepare image (resize and encode to AVIF)
|
|
||||||
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
|
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
|
||||||
const avifFile = await prepareArticlePhoto(file);
|
const avifFile = await prepareArticlePhoto(file);
|
||||||
await this.props.uploadCategoryImage({
|
await categoryAutomat.actions.uploadCategoryImage({
|
||||||
categoryId: item.id,
|
categoryId: item.id,
|
||||||
file: avifFile,
|
file: avifFile,
|
||||||
});
|
});
|
||||||
@@ -396,7 +409,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
onPhotoDelete: () => {
|
onPhotoDelete: () => {
|
||||||
void this.props.deleteCategoryImage(item.id)
|
void categoryAutomat.actions.deleteCategoryImage(item.id)
|
||||||
.catch(notifyError);
|
.catch(notifyError);
|
||||||
},
|
},
|
||||||
nameInput: (
|
nameInput: (
|
||||||
@@ -478,36 +491,4 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
export default withCategoryRoute(ArtikelKategorienPage);
|
||||||
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));
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { requireArray, requireDefined, requireObject } from '../../utils/apiShap
|
|||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
|
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
|
||||||
|
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||||
import { extractPdfText } from '../../utils/pdfText';
|
import { extractPdfText } from '../../utils/pdfText';
|
||||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||||
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
||||||
@@ -477,12 +478,19 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const text = await extractPdfText(file, { layout: true });
|
const text = await extractPdfText(file, { layout: true });
|
||||||
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
|
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
|
||||||
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
|
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 = {
|
const txtAttachment = {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
file: txtFile,
|
file: txtFile,
|
||||||
name: txtFile.name,
|
name: txtFile.name,
|
||||||
size: txtFile.size,
|
size: txtFile.size,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
|
thumbnail: txtThumbnail,
|
||||||
text,
|
text,
|
||||||
parentPdfId: pdfId,
|
parentPdfId: pdfId,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
@@ -499,6 +507,12 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Lesen der Datei:', 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 attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const textAttachment = {
|
const textAttachment = {
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
@@ -506,6 +520,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: isCsv ? 'text/csv' : 'text/plain',
|
type: isCsv ? 'text/csv' : 'text/plain',
|
||||||
|
thumbnail,
|
||||||
text,
|
text,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -537,6 +552,13 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
|
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
|
||||||
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
|
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(
|
const existingTxt = this.state.attachments?.find(
|
||||||
(a) => a.parentPdfId === attachment.id || a.name === txtName,
|
(a) => a.parentPdfId === attachment.id || a.name === txtName,
|
||||||
);
|
);
|
||||||
@@ -546,6 +568,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
file: txtFile,
|
file: txtFile,
|
||||||
size: txtFile.size,
|
size: txtFile.size,
|
||||||
text,
|
text,
|
||||||
|
thumbnail: txtThumbnail || existingTxt.thumbnail,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -555,6 +578,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
name: txtFile.name,
|
name: txtFile.name,
|
||||||
size: txtFile.size,
|
size: txtFile.size,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
|
thumbnail: txtThumbnail,
|
||||||
text,
|
text,
|
||||||
parentPdfId: attachment.id,
|
parentPdfId: attachment.id,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
@@ -996,7 +1020,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSaveTextEditor(newText) {
|
async handleSaveTextEditor(newText) {
|
||||||
const attachment = this.state.textEditorAttachment;
|
const attachment = this.state.textEditorAttachment;
|
||||||
if (!attachment) return;
|
if (!attachment) return;
|
||||||
|
|
||||||
@@ -1004,11 +1028,19 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
type: 'text/plain;charset=utf-8',
|
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({
|
this.automat.actions.updateAttachment({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
file: newFile,
|
file: newFile,
|
||||||
size: newFile.size,
|
size: newFile.size,
|
||||||
text: newText,
|
text: newText,
|
||||||
|
thumbnail,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
111
src/utils/categoryUtils.js
Normal 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
144
src/utils/textThumbnail.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user