Compare commits
258 Commits
deb22d8b4f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283ce3dc5f | ||
|
|
863422df8e | ||
|
|
9841002398 | ||
|
|
bc11a2c3ab | ||
|
|
ec9f2d9816 | ||
|
|
bc284c6d42 | ||
|
|
0181e08657 | ||
|
|
6071e59d30 | ||
|
|
e7103ac340 | ||
|
|
9c6d5d79e4 | ||
|
|
7c33c097d4 | ||
|
|
76e911ebaf | ||
|
|
d2139fee45 | ||
|
|
8a416ce384 | ||
|
|
7fe9f90f2c | ||
|
|
456389be1b | ||
|
|
e788c960d5 | ||
|
|
b7046d3bc6 | ||
|
|
fcaf3d5821 | ||
|
|
ca28f56f3f | ||
|
|
545eb98191 | ||
|
|
ee80646c1c | ||
|
|
df015da8fc | ||
|
|
2d1a7364f2 | ||
|
|
f6eb31b70d | ||
|
|
6719cf9d51 | ||
|
|
cdb031921b | ||
|
|
9cb82745e3 | ||
|
|
56d376c9bd | ||
|
|
72f168f188 | ||
|
|
eed979bf11 | ||
|
|
28d6fece46 | ||
|
|
1b60965cea | ||
|
|
d3e16fd2b7 | ||
|
|
3bb861907e | ||
|
|
6ca7597c59 | ||
|
|
05f1311892 | ||
|
|
dba9ae5cf0 | ||
|
|
8b680b5a1a | ||
|
|
f555a1c9dd | ||
|
|
867d550814 | ||
|
|
afb33cde38 | ||
|
|
5fcc5670d9 | ||
|
|
6851e7c16f | ||
|
|
6e9d706bd6 | ||
|
|
d21def186e | ||
|
|
6d299c1f43 | ||
|
|
87ef79bea3 | ||
|
|
2e69f49e4d | ||
|
|
003b198b86 | ||
|
|
b13d994058 | ||
|
|
550586f46e | ||
|
|
3c51de06c2 | ||
|
|
e45f35e97b | ||
|
|
0453680c59 | ||
|
|
23e4a0951c | ||
|
|
96d0ae6742 | ||
|
|
fab291f68e | ||
|
|
481ea16a6a | ||
|
|
75691e6afc | ||
|
|
f52ab1be0c | ||
|
|
13e1821355 | ||
|
|
eeb4b92a79 | ||
|
|
3801ac4e52 | ||
|
|
31fdc30c5a | ||
|
|
bf39f4d017 | ||
|
|
2f946442c8 | ||
|
|
35144c7187 | ||
|
|
5dd45916b8 | ||
|
|
b9bd0380cb | ||
|
|
57347bdef9 | ||
|
|
778840ec5e | ||
|
|
82f00ad593 | ||
|
|
985f4a3da2 | ||
|
|
a32ea19b5e | ||
|
|
a2103b9d0b | ||
|
|
ae245af6d7 | ||
|
|
fbf107843f | ||
|
|
b09100b13e | ||
|
|
b9abe8ec55 | ||
|
|
3d571a988a | ||
|
|
18fa62514a | ||
|
|
07206674c1 | ||
|
|
70c7d5eff2 | ||
|
|
4f367ee943 | ||
|
|
c77297af72 | ||
|
|
9bc6d55e85 | ||
|
|
65c908f44e | ||
|
|
42241ea541 | ||
|
|
b727e00928 | ||
|
|
067207c58e | ||
|
|
90ee11fefb | ||
|
|
c86c6a84f5 | ||
|
|
e953bf4c8c | ||
|
|
935e01cb30 | ||
|
|
883c856bdb | ||
|
|
3cec22e4e5 | ||
|
|
c84a504a5c | ||
|
|
06d0dd3bb2 | ||
|
|
7286cd8bf2 | ||
|
|
7499fbc36e | ||
|
|
8961d662e5 | ||
|
|
c647068067 | ||
|
|
c77f367216 | ||
|
|
578387adca | ||
|
|
037a2b0ae4 | ||
|
|
eb15bcb83b | ||
|
|
9b40d1db87 | ||
|
|
f7ee9182ec | ||
|
|
479cac85a5 | ||
|
|
0a9deac0da | ||
|
|
abd40ff4fe | ||
|
|
5faea634ba | ||
|
|
35cfea91f8 | ||
|
|
9f9310e1ff | ||
|
|
c211040cd3 | ||
|
|
ac9034685f | ||
|
|
328ba164ec | ||
|
|
180f65f4d6 | ||
|
|
94f385e753 | ||
|
|
587170ce9b | ||
|
|
199bf869a6 | ||
|
|
596d0dc6f1 | ||
|
|
c15fa0a53e | ||
|
|
9cb93bddeb | ||
|
|
b42b63a0af | ||
|
|
cf85ec102d | ||
|
|
5644678b7d | ||
|
|
3df8c5e9c7 | ||
|
|
ea52753ef9 | ||
|
|
33684fc860 | ||
|
|
1b4b31b016 | ||
|
|
dc73905a19 | ||
|
|
ab9d5fe413 | ||
|
|
0a59a0987a | ||
|
|
691854fdd4 | ||
|
|
666d84b9f3 | ||
|
|
b8f226bd39 | ||
|
|
0d11dd5014 | ||
|
|
d1ccbf335c | ||
|
|
c332be8916 | ||
|
|
7080382f56 | ||
|
|
45c1067d9f | ||
|
|
582bf04476 | ||
|
|
c0ae79ba6b | ||
|
|
01d17ebb25 | ||
|
|
20d83b00fc | ||
|
|
54066fba7f | ||
|
|
b3051953b0 | ||
|
|
cb2f6ad37f | ||
|
|
bea427f3df | ||
|
|
bd16df64a1 | ||
|
|
6df833b362 | ||
|
|
e63abfe2c0 | ||
|
|
d729423249 | ||
|
|
e031458a42 | ||
|
|
bb22da7aae | ||
|
|
0b0ffb2203 | ||
|
|
b3a81efe46 | ||
|
|
82db75ac0f | ||
|
|
5d8ac3a8eb | ||
|
|
0851c31970 | ||
|
|
d8c1d44652 | ||
|
|
23a198b0c1 | ||
|
|
7ad8a3a301 | ||
|
|
4a7a02a0a4 | ||
|
|
8e098b145e | ||
|
|
7a4d452109 | ||
|
|
99d95ee234 | ||
|
|
f5a5cb51e3 | ||
|
|
52411ac45d | ||
|
|
5c57097ad0 | ||
|
|
a592cc30f6 | ||
|
|
764d6f850a | ||
|
|
d851c6e1e9 | ||
|
|
7bc1da4de6 | ||
|
|
ff165799d4 | ||
|
|
a52366c477 | ||
|
|
2b2b5b945d | ||
|
|
fafeec93b6 | ||
|
|
beed294cb0 | ||
|
|
8e783a571f | ||
|
|
7e5b262d75 | ||
|
|
47bac6024c | ||
|
|
657d184d1c | ||
|
|
a90776bb8b | ||
|
|
670184c633 | ||
|
|
7114258a96 | ||
|
|
40bf70a41c | ||
|
|
37acaf9554 | ||
|
|
9817d4abda | ||
|
|
954f276404 | ||
|
|
77f6a0815f | ||
|
|
360660b850 | ||
|
|
6abadac120 | ||
|
|
bcd61fcf5e | ||
|
|
6e0b72b34b | ||
|
|
39aa898324 | ||
|
|
7ce2e3216f | ||
|
|
002f63a47a | ||
|
|
ed7bb80006 | ||
|
|
e2a2d02cb4 | ||
|
|
eb4654dfbf | ||
|
|
c62834833d | ||
|
|
fdb67b7221 | ||
|
|
c76777d0b8 | ||
|
|
c54d932e36 | ||
|
|
a0d71f70b7 | ||
|
|
b8fdef4e11 | ||
|
|
4188d8f46f | ||
|
|
49f8f2f93f | ||
|
|
61dd2f3a5d | ||
|
|
62fa58e4ec | ||
|
|
e7b3044903 | ||
|
|
61076a9ef2 | ||
|
|
ee3012670f | ||
|
|
937f434641 | ||
|
|
424609a0f6 | ||
|
|
2dd6533481 | ||
|
|
c0600ecb3b | ||
|
|
56a86fc005 | ||
|
|
f6cb3d3e35 | ||
|
|
e2bf08d340 | ||
|
|
bf7ff349f3 | ||
|
|
bc583a180f | ||
|
|
345d6f5752 | ||
|
|
cd530ed448 | ||
|
|
94fcac0795 | ||
|
|
8f68f7d224 | ||
|
|
d715b9c4e5 | ||
|
|
4c5ccf7f64 | ||
|
|
1ad9b14aa6 | ||
|
|
df22f77369 | ||
|
|
21663e4681 | ||
|
|
6b0839639d | ||
|
|
2cfa263044 | ||
|
|
858e413627 | ||
|
|
a0a40b243a | ||
|
|
15dce3c1cf | ||
|
|
627868eb2e | ||
|
|
d5d45e57d4 | ||
|
|
59c3b901ae | ||
|
|
afff1a948c | ||
|
|
0a073f7cf4 | ||
|
|
ed2ac2c2c3 | ||
|
|
d0fed9a2d1 | ||
|
|
36095d66c9 | ||
|
|
cc914085fc | ||
|
|
d86af21125 | ||
|
|
a96a80a036 | ||
|
|
95523ce9f3 | ||
|
|
31467f106a | ||
|
|
1e2f57e941 | ||
|
|
e8b2ead83f | ||
|
|
5ca1644d0c | ||
|
|
83dc698322 | ||
|
|
5e8037d430 | ||
|
|
45cc69730b |
File diff suppressed because it is too large
Load Diff
@@ -8,4 +8,13 @@ class componets disconnected from state, in automat.
|
|||||||
|
|
||||||
Reference:
|
Reference:
|
||||||
Component: src/routes/artikel/marken.jsx
|
Component: src/routes/artikel/marken.jsx
|
||||||
it's Automat: src/automats/brandAutomat.js
|
it's Automat: src/automats/brandAutomat.js
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
use vs code region markers in code files
|
||||||
|
use:
|
||||||
|
// MARK: calls startApi from ./api/index.js
|
||||||
|
atop important functions
|
||||||
|
|
||||||
|
don't use tooltips
|
||||||
@@ -27,12 +27,16 @@ Reference guide for the backend LLM agent when emitting route navigation actions
|
|||||||
| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` |
|
| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` |
|
||||||
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
|
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
|
||||||
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
|
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
|
||||||
| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` |
|
| **Order List (Auftrag / Bestellungen)** | `/auftrag` | Static (legacy `/auftrag/bestellung` redirects here) | `/auftrag` |
|
||||||
| **New Quote** | `/auftrag/neu` | Static draft form | `/auftrag/neu` |
|
|
||||||
| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
|
|
||||||
| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |
|
|
||||||
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
|
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
|
||||||
| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` |
|
| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` |
|
||||||
|
| **Quote List (Angebote)** | `/auftrag/angebot` | Static | `/auftrag/angebot` |
|
||||||
|
| **New Quote** | `/auftrag/neuesangebot` | Static draft form (Neues Angebot) | `/auftrag/neuesangebot` |
|
||||||
|
| **Quote Detail** | `/auftrag/:quoteId` or `/auftrag/angebot/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
|
||||||
|
| **Shipments (Ausliefern)** | `/auftrag/ausliefern` | Static (shipment manifests & parcels) | `/auftrag/ausliefern` |
|
||||||
|
| **Payments (Zahlungen)** | `/auftrag/zahlungen` | Static (order payments ledger) | `/auftrag/zahlungen` |
|
||||||
|
| **Carriers (Versandarten)** | `/auftrag/versandarten` | Static (Versanddienstleister & Versandarten) | `/auftrag/versandarten` |
|
||||||
|
|
||||||
| **Customer List** | `/kunde` | Static | `/kunde` |
|
| **Customer List** | `/kunde` | Static | `/kunde` |
|
||||||
| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` |
|
| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` |
|
||||||
| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` |
|
| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` |
|
||||||
@@ -66,10 +70,13 @@ Reference guide for the backend LLM agent when emitting route navigation actions
|
|||||||
|
|
||||||
### Quotes & Orders (`/auftrag`)
|
### Quotes & Orders (`/auftrag`)
|
||||||
- Distinction:
|
- Distinction:
|
||||||
- Quotes (Angebote) live at `/auftrag` and `/auftrag/:quoteId`.
|
- Orders (Auftrag / Bestellungen) live at `/auftrag` and `/auftrag/bestellung/:orderId`.
|
||||||
- Orders (Bestellungen) live at `/auftrag/bestellung` and `/auftrag/bestellung/:orderId`.
|
- Quotes (Angebote) live at `/auftrag/angebot` and `/auftrag/:quoteId` (or `/auftrag/angebot/:quoteId`).
|
||||||
|
- Shipments (Ausliefern) live at `/auftrag/ausliefern` (connected to shipment manifests and packages).
|
||||||
|
- Payments (Zahlungen) live at `/auftrag/zahlungen` (payment bookings and payment balances).
|
||||||
- Both IDs (`quoteId`, `orderId`) are document IDs.
|
- Both IDs (`quoteId`, `orderId`) are document IDs.
|
||||||
|
|
||||||
|
|
||||||
### Customers (`/kunde`)
|
### Customers (`/kunde`)
|
||||||
- `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`).
|
- `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`).
|
||||||
- Opens contact addresses, communications, and customer data tiles.
|
- Opens contact addresses, communications, and customer data tiles.
|
||||||
|
|||||||
1
.vscode/tmstate.json
vendored
Normal file
1
.vscode/tmstate.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"groups":{},"history":{},"addons":{},"selectedGroup":null,"previousSelectedGroup":null,"quickSlots":{}}
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -259,6 +259,9 @@ npm run build
|
|||||||
|
|
||||||
# Preview production build
|
# Preview production build
|
||||||
npm run preview
|
npm run preview
|
||||||
|
|
||||||
|
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
|
||||||
|
npm run automat-graph
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@@ -425,7 +428,7 @@ The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/d
|
|||||||
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
|
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
|
||||||
├── /auftrag
|
├── /auftrag
|
||||||
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
|
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
|
||||||
│ ├── /neu → AuftragAngebotNeuPage (Create quote)
|
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
|
||||||
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
|
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
|
||||||
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
|
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
|
||||||
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
|
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
|
||||||
@@ -488,6 +491,13 @@ npm run cert # Generates local certs in ./certs/
|
|||||||
npm run dev:https # Runs Vite HTTPS server on port 4308
|
npm run dev:https # Runs Vite HTTPS server on port 4308
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`)
|
||||||
|
- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`).
|
||||||
|
- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`.
|
||||||
|
- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI).
|
||||||
|
- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange).
|
||||||
|
- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Agent Cheatsheet & Code Recipes
|
## 16. Agent Cheatsheet & Code Recipes
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ future test cases.
|
|||||||
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
|
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
|
||||||
requirement without needing HTTPS on the frontend dev server.
|
requirement without needing HTTPS on the frontend dev server.
|
||||||
|
|
||||||
## Architecture
|
## Architecture & State Management
|
||||||
|
|
||||||
|
- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs).
|
||||||
|
- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships.
|
||||||
|
- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm.
|
||||||
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
|
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
|
||||||
- `src/auth/googleSignIn.js` — Google Identity Services helper.
|
- `src/auth/googleSignIn.js` — Google Identity Services helper.
|
||||||
- `src/auth/AuthProvider.jsx` — session state + login/logout.
|
- `src/auth/AuthProvider.jsx` — session state + login/logout.
|
||||||
- `src/auth/useAuth.js` — auth context hook.
|
- `src/auth/useAuth.js` — auth context hook.
|
||||||
- `src/components/*` — small function components with local state.
|
- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors.
|
||||||
|
|||||||
121
automat-graph.dot
Normal file
121
automat-graph.dot
Normal file
File diff suppressed because one or more lines are too long
1676
automat-graph.svg
Normal file
1676
automat-graph.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 199 KiB |
@@ -2,6 +2,67 @@ import js from '@eslint/js';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
|
const banMuiTooltipRule = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Ban MUI Tooltip component',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
badDesign: '<Tooltip> is bad design',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const muiTooltipIdentifiers = new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
if (node.source.value === '@mui/material/Tooltip') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
for (const specifier of node.specifiers) {
|
||||||
|
if (specifier.local?.name) {
|
||||||
|
muiTooltipIdentifiers.add(specifier.local.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.source.value === '@mui/material') {
|
||||||
|
for (const specifier of node.specifiers) {
|
||||||
|
if (
|
||||||
|
specifier.type === 'ImportSpecifier' &&
|
||||||
|
specifier.imported?.name === 'Tooltip'
|
||||||
|
) {
|
||||||
|
context.report({
|
||||||
|
node: specifier,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
if (specifier.local?.name) {
|
||||||
|
muiTooltipIdentifiers.add(specifier.local.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
let tagName = '';
|
||||||
|
if (node.name.type === 'JSXIdentifier') {
|
||||||
|
tagName = node.name.name;
|
||||||
|
} else if (node.name.type === 'JSXMemberExpression') {
|
||||||
|
tagName = node.name.property.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
@@ -32,10 +93,16 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
|
react: {
|
||||||
|
rules: {
|
||||||
|
'no-mui-tooltip': banMuiTooltipRule,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'react/no-mui-tooltip': 'error',
|
||||||
'no-unused-vars': [
|
'no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
|
|||||||
884
generate-automat-graph.js
Normal file
884
generate-automat-graph.js
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outputPath = join(__dirname, 'automat-graph.svg');
|
||||||
|
const dotOutputPath = join(__dirname, 'automat-graph.dot');
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: '#1e2430',
|
||||||
|
tableBorder: '#3f4c5f',
|
||||||
|
headerBg: '#33415c',
|
||||||
|
headerText: '#e2e8f0',
|
||||||
|
rowBg: '#2a3242',
|
||||||
|
rowBgAlt: '#272e3d',
|
||||||
|
keyBg: '#2b3a52',
|
||||||
|
text: '#d8dee9',
|
||||||
|
muted: '#8b95a7',
|
||||||
|
clusterFill: '#202a3a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BADGE_COLORS = {
|
||||||
|
STATE: { fg: '#c084fc', bg: '#2b1a42' }, // Purple
|
||||||
|
ACTION: { fg: '#4ade80', bg: '#143826' }, // Green
|
||||||
|
SELECT: { fg: '#38bdf8', bg: '#182b40' }, // Sky Blue
|
||||||
|
API: { fg: '#fbbf24', bg: '#3b2c12' }, // Amber
|
||||||
|
SSE: { fg: '#f87171', bg: '#3a181e' }, // Rose/Red
|
||||||
|
HOOK: { fg: '#fb923c', bg: '#382414' }, // Orange/Gold
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_COLORS = [
|
||||||
|
'#5aa7ff', // Blue
|
||||||
|
'#6bdf9a', // Mint Green
|
||||||
|
'#f5c15c', // Golden Amber
|
||||||
|
'#ff7fc2', // Pink
|
||||||
|
'#c79bff', // Lavender
|
||||||
|
'#5cd6d6', // Cyan
|
||||||
|
'#ff9f5a', // Coral
|
||||||
|
'#8f95ff', // Indigo
|
||||||
|
'#ff6b6b', // Soft Red
|
||||||
|
'#58d18a', // Emerald
|
||||||
|
'#4fc4e8', // Sky
|
||||||
|
'#a98cff', // Violet
|
||||||
|
'#ff8f66', // Tangerine
|
||||||
|
'#4ecb8f', // Seafoam
|
||||||
|
'#ff7aa8', // Rose
|
||||||
|
'#e0b25a', // Sand
|
||||||
|
'#56c8c8', // Aqua
|
||||||
|
'#ff5f9e', // Fuchsia
|
||||||
|
'#ffb25a', // Ochre
|
||||||
|
'#66b3ff', // Periwinkle
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLUSTERS = [
|
||||||
|
{
|
||||||
|
id: 'session_realtime',
|
||||||
|
label: 'Session & Real-Time Hub',
|
||||||
|
automats: ['authAutomat', 'sseAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'company_basedata',
|
||||||
|
label: 'Company & Consolidated Base Data',
|
||||||
|
automats: ['companyAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'catalog_products',
|
||||||
|
label: 'Catalog & Products',
|
||||||
|
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'search_indexing',
|
||||||
|
label: 'Search & Indexing',
|
||||||
|
automats: ['articleIndexAutomat', 'articleSearchAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchasing_logistics',
|
||||||
|
label: 'Deliveries & Inbound',
|
||||||
|
automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales_crm',
|
||||||
|
label: 'Sales & CRM',
|
||||||
|
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai_workspace',
|
||||||
|
label: 'AI Assistant & Workspace',
|
||||||
|
automats: ['chatAutomat', 'vfsAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit_grids',
|
||||||
|
label: 'Audit & Virtual Grids',
|
||||||
|
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat', 'ledgerHistoryGridAutomat'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system_ui',
|
||||||
|
label: 'UI, Navigation & System',
|
||||||
|
automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str, max = 30) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete metadata for every Automat in Dashfront.
|
||||||
|
*/
|
||||||
|
const AUTOMAT_SPECS = [
|
||||||
|
// 1. Session & Real-Time Hub
|
||||||
|
{
|
||||||
|
id: 'authAutomat',
|
||||||
|
title: 'authAutomat',
|
||||||
|
subtitle: 'Always-On Session & Multi-Tenant Gateway',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
||||||
|
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
||||||
|
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
||||||
|
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
|
||||||
|
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
||||||
|
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
||||||
|
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
||||||
|
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
||||||
|
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
||||||
|
{ type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
|
||||||
|
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sseAutomat',
|
||||||
|
title: 'sseAutomat',
|
||||||
|
subtitle: 'Single Persistent SSE Connection',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'status', desc: '\'connected\' | \'error\'' },
|
||||||
|
{ type: 'STATE', name: 'clientId (cid)', desc: 'Tab-scoped UUID in session' },
|
||||||
|
{ type: 'ACTION', name: 'reconnect()', desc: 'Cycle on company switch' },
|
||||||
|
{ type: 'ACTION', name: 'on(event, handler)', desc: 'Register signal listener' },
|
||||||
|
{ type: 'ACTION', name: 'watchArticle(sku)', desc: 'Attach article-<sku>' },
|
||||||
|
{ type: 'SELECT', name: 'invalidateArticle(sku)', desc: 'Silent cache refresh' },
|
||||||
|
{ type: 'SSE', name: 'category / articles', desc: 'Catalog mutation events', port: 'p_sse_events' },
|
||||||
|
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment events' },
|
||||||
|
{ type: 'API', name: 'GET /api/events', desc: 'Persistent stream' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Company & Consolidated Base Data
|
||||||
|
{
|
||||||
|
id: 'companyAutomat',
|
||||||
|
title: 'companyAutomat',
|
||||||
|
subtitle: 'Consolidated Company & Base Data Hub',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' },
|
||||||
|
{ type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' },
|
||||||
|
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' },
|
||||||
|
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' },
|
||||||
|
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' },
|
||||||
|
{ type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' },
|
||||||
|
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' },
|
||||||
|
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' },
|
||||||
|
{ type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' },
|
||||||
|
{ type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' },
|
||||||
|
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' },
|
||||||
|
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' },
|
||||||
|
{ type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' },
|
||||||
|
{ type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' },
|
||||||
|
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
|
||||||
|
{ type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. Catalog & Products
|
||||||
|
{
|
||||||
|
id: 'categoryAutomat',
|
||||||
|
title: 'categoryAutomat',
|
||||||
|
subtitle: 'Hierarchical Category Tree',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'categories: Map', desc: 'ID -> Category Node Map', port: 'p_cat_categories' },
|
||||||
|
{ type: 'STATE', name: 'rootCategoryId', desc: 'Company root node ID' },
|
||||||
|
{ type: 'STATE', name: 'articleCount', desc: 'Total parent items' },
|
||||||
|
{ type: 'ACTION', name: 'loadTree()', desc: 'Fetch tree & warm views', port: 'p_cat_loadTree' },
|
||||||
|
{ type: 'ACTION', name: 'createCategory()', desc: 'Insert child node' },
|
||||||
|
{ type: 'ACTION', name: 'moveCategory()', desc: 'Parent reparenting' },
|
||||||
|
{ type: 'ACTION', name: 'deleteCategory()', desc: 'Prune subtree' },
|
||||||
|
{ type: 'SELECT', name: 'getCategoryBreadcrumbs()', desc: 'Hierarchical path trace' },
|
||||||
|
{ type: 'API', name: 'GET /api/category', desc: 'Recursive tree load' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'categoryViewAutomat',
|
||||||
|
title: 'categoryViewAutomat',
|
||||||
|
subtitle: 'KeyedAutomat (Slug Path)',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'views: Map<slug, Data>', desc: 'Articles & direct children' },
|
||||||
|
{ type: 'ACTION', name: 'loadAllCategoryViews()', desc: 'Prewarm all tree slugs' },
|
||||||
|
{ type: 'ACTION', name: 'reloadLoadedCategoryViews()', desc: 'Refresh cached views' },
|
||||||
|
{ type: 'ACTION', name: 'invalidateAllCategoryViews()', desc: 'Clear on SSE signal' },
|
||||||
|
{ type: 'SELECT', name: 'collectLoadedCategoryViewData()', desc: 'Extract union data', port: 'p_catview_collect' },
|
||||||
|
{ type: 'SELECT', name: 'getOrCreateCategoryAutomat()', desc: 'Per-slug reactive store' },
|
||||||
|
{ type: 'API', name: 'GET /api/category/:id/articles', desc: 'Category article batch' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'artikelAutomat',
|
||||||
|
title: 'artikelAutomat',
|
||||||
|
subtitle: 'In-Memory Article Record Cache',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'articles: Map<sku, Object>', desc: 'Deduplicated SKU cache', port: 'p_art_set' },
|
||||||
|
{ type: 'ACTION', name: 'loadArticle(sku)', desc: 'Fetch or cached promise' },
|
||||||
|
{ type: 'ACTION', name: 'reloadArticle(sku)', desc: 'Silent background sync' },
|
||||||
|
{ type: 'ACTION', name: 'setArticleName(sku, name)', desc: 'Optimistic rename', port: 'p_art_setName' },
|
||||||
|
{ type: 'ACTION', name: 'clearArticle(sku)', desc: 'Evict SKU from cache' },
|
||||||
|
{ type: 'SELECT', name: 'getArticle(sku)', desc: 'Sync constructor read' },
|
||||||
|
{ type: 'SELECT', name: 'createArticleSelector(sku)', desc: 'Fine-grained selector' },
|
||||||
|
{ type: 'API', name: 'GET /api/article/:sku', desc: 'Full article document' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'articleDetailAutomat',
|
||||||
|
title: 'articleDetailAutomat',
|
||||||
|
subtitle: 'KeyedAutomat (SKU)',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'articleHistory: Map<sku>', desc: 'Change history log', port: 'p_artdet_hist' },
|
||||||
|
{ type: 'STATE', name: 'articleSupplier: Map<sku>', desc: 'Assigned supplier list' },
|
||||||
|
{ type: 'ACTION', name: 'articleHistoryAutomat.load()', desc: 'Fetch SKU audit trail' },
|
||||||
|
{ type: 'ACTION', name: 'articleSupplierAutomat.load()', desc: 'Fetch supplier catalog' },
|
||||||
|
{ type: 'API', name: 'GET /api/article/:sku/history', desc: 'Audit change entries' },
|
||||||
|
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. Search & Discovery
|
||||||
|
{
|
||||||
|
id: 'articleIndexAutomat',
|
||||||
|
title: 'articleIndexAutomat',
|
||||||
|
subtitle: 'Derived Client Search Index',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'items: ArticleIndexRecord[]', desc: 'De-duplicated article union', port: 'p_idx_items' },
|
||||||
|
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Index lifecycle flag' },
|
||||||
|
{ type: 'ACTION', name: 'buildArticleIndex()', desc: 'Derive from category views' },
|
||||||
|
{ type: 'ACTION', name: 'setArticleIndexName()', desc: 'Patch on rename' },
|
||||||
|
{ type: 'ACTION', name: 'reload()', desc: 'Re-derive from views' },
|
||||||
|
{ type: 'SELECT', name: 'selectArticleIndexItems()', desc: 'Fast record projection' },
|
||||||
|
{ type: 'SELECT', name: 'ensureLoaded()', desc: 'Await warm views' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'articleSearchAutomat',
|
||||||
|
title: 'articleSearchAutomat',
|
||||||
|
subtitle: 'In-Memory FlexSearch Engine',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'target: string', desc: 'Active search query' },
|
||||||
|
{ type: 'STATE', name: 'filter: string', desc: 'Active category filter' },
|
||||||
|
{ type: 'STATE', name: 'result: ArticleSearchResult', desc: 'Facets, recordset, count' },
|
||||||
|
{ type: 'ACTION', name: 'search(target, filter)', desc: 'Synchronous indexed query' },
|
||||||
|
{ type: 'SELECT', name: 'selectSearchResult()', desc: 'Reactive search outcome' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. Deliveries & Inbound
|
||||||
|
{
|
||||||
|
id: 'deliveryAutomat',
|
||||||
|
title: 'deliveryAutomat',
|
||||||
|
subtitle: 'Chunk-Paginated Deliveries',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
|
||||||
|
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
|
||||||
|
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch & sync' },
|
||||||
|
{ type: 'ACTION', name: 'reset()', desc: 'Clear on session switch' },
|
||||||
|
{ type: 'API', name: 'GET /api/delivery', desc: 'Inbound delivery log' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deliveryEditorAutomat',
|
||||||
|
title: 'deliveryEditorAutomat',
|
||||||
|
subtitle: 'Inbound Receipt & OCR Machine',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'lines: DeliveryLine[]', desc: 'Uncommitted draft lines', port: 'p_deled_lines' },
|
||||||
|
{ type: 'STATE', name: 'deliveryEntity', desc: 'Selected supplier ID', port: 'p_deled_supplier' },
|
||||||
|
{ type: 'STATE', name: 'attachments: File[]', desc: 'Invoice PDFs & OCR results' },
|
||||||
|
{ type: 'STATE', name: 'draftCount: number', desc: 'Uncommitted lines for badge' },
|
||||||
|
{ type: 'ACTION', name: 'addLine() / updateLine()', desc: 'Interactive item editing' },
|
||||||
|
{ type: 'ACTION', name: 'commitDelivery()', desc: 'Persist goods receipt', port: 'p_deled_commit' },
|
||||||
|
{ type: 'SELECT', name: 'buildSupplierArticlesMap()', desc: 'Auto-match SKU/EAN' },
|
||||||
|
{ type: 'API', name: 'POST /api/delivery', desc: 'Create goods receipt' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. Sales & CRM
|
||||||
|
{
|
||||||
|
id: 'quoteAutomat',
|
||||||
|
title: 'quoteAutomat',
|
||||||
|
subtitle: 'Chunk-Paginated Quotes',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' },
|
||||||
|
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' },
|
||||||
|
{ type: 'ACTION', name: 'reset()', desc: 'Reset on logout' },
|
||||||
|
{ type: 'API', name: 'GET /api/quote', desc: 'Company quote list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orderAutomat',
|
||||||
|
title: 'orderAutomat',
|
||||||
|
subtitle: 'Chunk-Paginated Order Engine',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'items: Order[]', desc: 'Sales orders & lines', port: 'p_order_items' },
|
||||||
|
{ type: 'STATE', name: 'total: number', desc: 'Unfiltered order count' },
|
||||||
|
{ type: 'STATE', name: 'onlyUnpaid / onlyUndelivered', desc: 'Fulfillment filters' },
|
||||||
|
{ type: 'ACTION', name: 'loadChunk(index)', desc: 'Chunk size 50 prefetch' },
|
||||||
|
{ type: 'ACTION', name: 'reload()', desc: 'Silent orders re-fetch' },
|
||||||
|
{ type: 'SELECT', name: 'selectOrders() / selectTotal()', desc: 'Filter-applied slice' },
|
||||||
|
{ type: 'API', name: 'GET /api/order', desc: 'Paged order stream' },
|
||||||
|
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment mutations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entityAutomat',
|
||||||
|
title: 'entityAutomat',
|
||||||
|
subtitle: 'Customer & Partner Directory',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
|
||||||
|
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
|
||||||
|
{ type: 'ACTION', name: 'reset()', desc: 'Reset on session switch' },
|
||||||
|
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 7. AI Assistant & Workspace
|
||||||
|
{
|
||||||
|
id: 'chatAutomat',
|
||||||
|
title: 'chatAutomat',
|
||||||
|
subtitle: 'AI Assistant Drawer & SSE',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'isOpen: boolean', desc: 'Docked chat drawer toggle' },
|
||||||
|
{ type: 'STATE', name: 'conversations: []', desc: 'Conversation sessions' },
|
||||||
|
{ type: 'STATE', name: 'messages: ModelMessage[]', desc: 'Live message transcript', port: 'p_chat_msg' },
|
||||||
|
{ type: 'STATE', name: 'sseStatus: \'connected\'', desc: 'Token streaming stream', port: 'p_chat_sse' },
|
||||||
|
{ type: 'ACTION', name: 'sendMessage(text)', desc: 'Prompt model with tools' },
|
||||||
|
{ type: 'ACTION', name: 'answerAskUser()', desc: 'Interactive tool modal' },
|
||||||
|
{ type: 'SELECT', name: 'userEnv / routeTools', desc: 'Client navigation tool', port: 'p_chat_tools' },
|
||||||
|
{ type: 'API', name: 'POST /api/modelconversation', desc: 'AI conversation session' },
|
||||||
|
{ type: 'SSE', name: 'modelconversation/events', desc: 'Live token stream' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vfsAutomat',
|
||||||
|
title: 'vfsAutomat',
|
||||||
|
subtitle: 'Virtual Filesystem Explorer',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'files: VfsFile[]', desc: 'Company VFS workspace' },
|
||||||
|
{ type: 'STATE', name: 'selectedFileName', desc: 'Current active file' },
|
||||||
|
{ type: 'STATE', name: 'fileContent / isDirty', desc: 'Editor buffer & diff' },
|
||||||
|
{ type: 'ACTION', name: 'selectFile() / saveFile()', desc: 'Applet editor buffer' },
|
||||||
|
{ type: 'ACTION', name: 'uploadFiles() / deleteFile()', desc: 'File manipulations' },
|
||||||
|
{ type: 'API', name: 'GET /api/vfs', desc: 'VFS directory index' },
|
||||||
|
{ type: 'SSE', name: 'vfs', desc: 'VFS mutation signal' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 8. Audit & Virtual Grids
|
||||||
|
{
|
||||||
|
id: 'articleHistoryGridAutomat',
|
||||||
|
title: 'articleHistoryGridAutomat',
|
||||||
|
subtitle: 'createGridAutomat (Article)',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||||
|
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||||
|
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||||
|
{ type: 'API', name: 'GET /api/article/history', desc: 'Infinite audit window' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'globalHistoryGridAutomat',
|
||||||
|
title: 'globalHistoryGridAutomat',
|
||||||
|
subtitle: 'createGridAutomat (Global)',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||||
|
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||||
|
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||||
|
{ type: 'API', name: 'GET /api/globalhistory', desc: 'Company global audit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ledgerHistoryGridAutomat',
|
||||||
|
title: 'ledgerHistoryGridAutomat',
|
||||||
|
subtitle: 'createGridAutomat (Ledger)',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||||||
|
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||||||
|
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||||||
|
{ type: 'API', name: 'GET /api/ledger/history', desc: 'Append-only ledger audit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 9. UI, Navigation & System
|
||||||
|
{
|
||||||
|
id: 'navigationAutomat',
|
||||||
|
title: 'navigationAutomat',
|
||||||
|
subtitle: 'Cross-Section Navigation Memory',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'lastDeliveryPath', desc: 'Active receipt subpath', port: 'p_nav_delpath' },
|
||||||
|
{ type: 'STATE', name: 'lastArticleSku', desc: 'Active SKU selection', port: 'p_nav_sku' },
|
||||||
|
{ type: 'STATE', name: 'lastCategorySlug / Path', desc: 'Active category tree node', port: 'p_nav_catslug' },
|
||||||
|
{ type: 'STATE', name: 'lastSearchTerm / Path', desc: 'Preserved search query' },
|
||||||
|
{ type: 'ACTION', name: 'setLastDeliveryPath()', desc: 'Restore on sidebar click' },
|
||||||
|
{ type: 'ACTION', name: 'setLastArticleSku()', desc: 'Restore SKU on section click' },
|
||||||
|
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'helpAutomat',
|
||||||
|
title: 'helpAutomat',
|
||||||
|
subtitle: 'IndexedDB Contextual Help',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'enabled: boolean', desc: 'Interactive help badges' },
|
||||||
|
{ type: 'STATE', name: 'activeHelpId: string|null', desc: 'Active popper ID', port: 'p_help_active' },
|
||||||
|
{ type: 'ACTION', name: 'toggle() / setEnabled()', desc: 'Toggle badge mode' },
|
||||||
|
{ type: 'ACTION', name: 'openPopper() / closePopper()', desc: 'Single active popper' },
|
||||||
|
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pwaAutomat',
|
||||||
|
title: 'pwaAutomat',
|
||||||
|
subtitle: 'Service Worker & Offline Lifecycle',
|
||||||
|
entries: [
|
||||||
|
{ type: 'STATE', name: 'needRefresh: boolean', desc: 'New SW release pending' },
|
||||||
|
{ type: 'STATE', name: 'offlineReady: boolean', desc: 'Offline assets cached' },
|
||||||
|
{ type: 'STATE', name: 'dialogOpen / updating', desc: 'Prompt modal state' },
|
||||||
|
{ type: 'ACTION', name: 'applyUpdate()', desc: 'Skip waiting & reload' },
|
||||||
|
{ type: 'ACTION', name: 'openDialog() / closeDialog()', desc: 'Modal controls' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-fidelity relationships between Automats:
|
||||||
|
* - Lifecycle synchronization (Auth / Session)
|
||||||
|
* - Real-time SSE dispatch & invalidation
|
||||||
|
* - Hierarchical catalog derivation & indexing
|
||||||
|
* - Purchasing & inventory workflows
|
||||||
|
* - AI tools & virtual workspace
|
||||||
|
*/
|
||||||
|
const EDGE_DEFS = [
|
||||||
|
// Session & Auth lifecycle
|
||||||
|
{ from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' },
|
||||||
|
{ from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' },
|
||||||
|
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' },
|
||||||
|
{ from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'globalHistoryGridAutomat', label: 'reload audit on authed', port: 'p_auth_status' },
|
||||||
|
{ from: 'authAutomat', to: 'ledgerHistoryGridAutomat', label: 'reload ledger on authed', port: 'p_auth_status' },
|
||||||
|
|
||||||
|
// Real-time SSE dispatch
|
||||||
|
{ from: 'sseAutomat', to: 'categoryAutomat', label: '\'category\' sse trigger', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'categoryViewAutomat', label: 'invalidate views on sse', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' },
|
||||||
|
{ from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' },
|
||||||
|
|
||||||
|
// Catalog, Indexing & Search pipeline
|
||||||
|
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' },
|
||||||
|
{ from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' },
|
||||||
|
{ from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' },
|
||||||
|
{ from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' },
|
||||||
|
{ from: 'categoryAutomat', to: 'articleSearchAutomat', label: 'categoryFacets', port: 'p_cat_categories' },
|
||||||
|
{ from: 'artikelAutomat', to: 'articleIndexAutomat', label: 'setArticleIndexName (optimistic)', port: 'p_art_setName' },
|
||||||
|
{ from: 'artikelAutomat', to: 'categoryAutomat', label: 'articleCount updates', port: 'p_art_set' },
|
||||||
|
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
|
||||||
|
|
||||||
|
// Purchasing & Deliveries
|
||||||
|
{ from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
|
||||||
|
{ from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' },
|
||||||
|
{ from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' },
|
||||||
|
|
||||||
|
// Sales & CRM
|
||||||
|
{ from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' },
|
||||||
|
{ from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' },
|
||||||
|
|
||||||
|
// AI Assistant & Workspace
|
||||||
|
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
|
||||||
|
{ from: 'chatAutomat', to: 'navigationAutomat', label: 'set_user_route tool', port: 'p_chat_tools' },
|
||||||
|
{ from: 'chatAutomat', to: 'sseAutomat', label: 'getSseClientId()', port: 'p_chat_sse' },
|
||||||
|
|
||||||
|
// UI Navigation & Preferences
|
||||||
|
{ from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' },
|
||||||
|
{ from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' },
|
||||||
|
{ from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' },
|
||||||
|
{ from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
|
||||||
|
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
|
||||||
|
|
||||||
|
// Self-loops
|
||||||
|
{ from: 'categoryAutomat', to: 'categoryAutomat', label: 'nested tree hierarchy', port: 'p_cat_categories' },
|
||||||
|
{ from: 'artikelAutomat', to: 'artikelAutomat', label: 'variants & parent SKU', port: 'p_art_set' },
|
||||||
|
{ from: 'chatAutomat', to: 'chatAutomat', label: 'stream delta appends', port: 'p_chat_msg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function edgeKey(edge) {
|
||||||
|
return `${edge.from}|${edge.to}|${edge.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignEdgeColors(edges) {
|
||||||
|
const colors = new Map();
|
||||||
|
let next = 0;
|
||||||
|
for (const edge of edges) {
|
||||||
|
const key = edgeKey(edge);
|
||||||
|
if (!colors.has(key)) {
|
||||||
|
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spanning ranking forest: only one primary outgoing edge per automat
|
||||||
|
* is used for rank constraints. Other edges get constraint=false to avoid
|
||||||
|
* vertical over-stretching.
|
||||||
|
*/
|
||||||
|
function selectRankingEdgeKeys(edges) {
|
||||||
|
const ranking = new Set();
|
||||||
|
const bySource = new Map();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (edge.from === edge.to) continue;
|
||||||
|
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||||
|
bySource.get(edge.from).push(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of bySource.values()) {
|
||||||
|
group.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
ranking.add(edgeKey(group[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranking;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegendLabel() {
|
||||||
|
const badgeRow = (type, desc) => {
|
||||||
|
const c = BADGE_COLORS[type];
|
||||||
|
return (
|
||||||
|
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="48">` +
|
||||||
|
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${type}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const row = (swatch, text) =>
|
||||||
|
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
|
||||||
|
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
|
||||||
|
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
|
||||||
|
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>Automat Graph Legend</B></FONT></TD></TR>` +
|
||||||
|
badgeRow('STATE', 'Reactive store state property') +
|
||||||
|
badgeRow('ACTION', 'State mutation action') +
|
||||||
|
badgeRow('SELECT', 'Reactive PureComponent selector') +
|
||||||
|
badgeRow('API', 'Backend REST/Fetch endpoint') +
|
||||||
|
badgeRow('SSE', 'Inbound Server-Sent Event trigger') +
|
||||||
|
badgeRow('HOOK', 'Auth / persistence lifecycle hook') +
|
||||||
|
row(
|
||||||
|
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||||||
|
'Inbound arrival port on Automat header'
|
||||||
|
) +
|
||||||
|
row(
|
||||||
|
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||||||
|
'Reactive dataflow / upstream trigger'
|
||||||
|
) +
|
||||||
|
`</TABLE>>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAutomatNode(spec, inboundCount = 0) {
|
||||||
|
if (spec.id === 'Legend') {
|
||||||
|
return buildLegendLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleFont =
|
||||||
|
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(spec.title)}</B></FONT>` +
|
||||||
|
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(spec.subtitle)}</FONT>`;
|
||||||
|
|
||||||
|
let headerInner;
|
||||||
|
if (inboundCount > 0) {
|
||||||
|
const portCells = [];
|
||||||
|
for (let i = 0; i < inboundCount; i++) {
|
||||||
|
portCells.push(
|
||||||
|
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headerInner =
|
||||||
|
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||||
|
`<TR>${portCells.join('')}</TR>` +
|
||||||
|
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
|
||||||
|
`</TABLE>`;
|
||||||
|
} else {
|
||||||
|
headerInner = titleFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
|
||||||
|
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [i, entry] of spec.entries.entries()) {
|
||||||
|
const bColor = BADGE_COLORS[entry.type] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||||||
|
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||||||
|
const portAttr = entry.port ? ` PORT="${escapeHtml(entry.port)}"` : '';
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
`<TR><TD${portAttr} COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||||||
|
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||||
|
`<TR>` +
|
||||||
|
`<TD BGCOLOR="${bColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="48">` +
|
||||||
|
`<FONT COLOR="${bColor.fg}" POINT-SIZE="8"><B>${escapeHtml(entry.type)}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||||||
|
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(entry.name)}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||||||
|
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(truncate(entry.desc, 30))}</FONT></TD>` +
|
||||||
|
`</TR></TABLE></TD></TR>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
|
||||||
|
rows.join('') +
|
||||||
|
`</TABLE>>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignEdgeAnchors(edges, positions) {
|
||||||
|
const headPort = new Map();
|
||||||
|
const tailCompass = new Map();
|
||||||
|
const inboundCounts = new Map();
|
||||||
|
|
||||||
|
const byTarget = new Map();
|
||||||
|
const bySource = new Map();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
|
||||||
|
byTarget.get(edge.to).push(edge);
|
||||||
|
|
||||||
|
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||||
|
bySource.get(edge.from).push(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [target, group] of byTarget) {
|
||||||
|
group.sort((a, b) => {
|
||||||
|
const ax = positions?.get(a.from)?.x ?? 0;
|
||||||
|
const bx = positions?.get(b.from)?.x ?? 0;
|
||||||
|
if (ax !== bx) return ax - bx;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
inboundCounts.set(target, group.length);
|
||||||
|
group.forEach((edge, i) => {
|
||||||
|
headPort.set(edgeKey(edge), `in${i}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of bySource.values()) {
|
||||||
|
group.forEach((edge, i) => {
|
||||||
|
if (edge.from === edge.to) {
|
||||||
|
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = positions?.get(edge.from);
|
||||||
|
const to = positions?.get(edge.to);
|
||||||
|
const fromX = from?.x ?? 0;
|
||||||
|
const toX = to?.x ?? fromX;
|
||||||
|
const goEast = toX >= fromX;
|
||||||
|
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headPort, tailCompass, inboundCounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDot(specsMap, edges, positions, anchors) {
|
||||||
|
const edgeColors = assignEdgeColors(edges);
|
||||||
|
const rankingKeys = selectRankingEdgeKeys(edges);
|
||||||
|
const { headPort, tailCompass, inboundCounts } =
|
||||||
|
anchors ?? assignEdgeAnchors(edges, positions);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'digraph automats {',
|
||||||
|
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
|
||||||
|
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||||||
|
' compound=true, mclimit=1.0];',
|
||||||
|
' node [shape=plaintext, fontname="Helvetica"];',
|
||||||
|
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
const clusteredAutomats = new Set();
|
||||||
|
|
||||||
|
for (const cluster of CLUSTERS) {
|
||||||
|
const presentInCluster = cluster.automats.filter((id) => specsMap.has(id));
|
||||||
|
if (presentInCluster.length === 0) continue;
|
||||||
|
|
||||||
|
lines.push(` subgraph cluster_${cluster.id} {`);
|
||||||
|
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
|
||||||
|
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
|
||||||
|
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
|
||||||
|
|
||||||
|
for (const autoId of presentInCluster) {
|
||||||
|
clusteredAutomats.add(autoId);
|
||||||
|
const spec = specsMap.get(autoId);
|
||||||
|
const inCount = inboundCounts.get(autoId) ?? 0;
|
||||||
|
const label = renderAutomatNode(spec, inCount);
|
||||||
|
lines.push(` ${autoId} [label=${label}];`);
|
||||||
|
}
|
||||||
|
lines.push(' }');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any unclustered automats
|
||||||
|
for (const [autoId, spec] of specsMap) {
|
||||||
|
if (clusteredAutomats.has(autoId)) continue;
|
||||||
|
const inCount = inboundCounts.get(autoId) ?? 0;
|
||||||
|
const label = renderAutomatNode(spec, inCount);
|
||||||
|
lines.push(` ${autoId} [label=${label}];`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const key = edgeKey(edge);
|
||||||
|
const color = edgeColors.get(key) ?? '#5aa7ff';
|
||||||
|
const isRanking = rankingKeys.has(key);
|
||||||
|
const hp = headPort.get(key) ?? 'in0';
|
||||||
|
const tc = tailCompass.get(key) ?? 'e';
|
||||||
|
|
||||||
|
const attrs = [
|
||||||
|
`color="${color}"`,
|
||||||
|
`fontcolor="${color}"`,
|
||||||
|
`tooltip="${escapeHtml(`${edge.from} → ${edge.to} (${edge.label})`)}"`,
|
||||||
|
`label=" ${escapeHtml(edge.label)} "`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isRanking && edge.from !== edge.to) {
|
||||||
|
attrs.push('constraint=false', 'weight=1');
|
||||||
|
} else {
|
||||||
|
attrs.push('weight=3');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source port from row entry if available, else exit compass
|
||||||
|
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
|
||||||
|
const toPort = `:${hp}:n`;
|
||||||
|
|
||||||
|
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlainPositions(plain) {
|
||||||
|
const positions = new Map();
|
||||||
|
for (const line of plain.split('\n')) {
|
||||||
|
if (!line.startsWith('node ')) continue;
|
||||||
|
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
||||||
|
if (!match) continue;
|
||||||
|
positions.set(match[1], {
|
||||||
|
x: Number(match[2]),
|
||||||
|
y: Number(match[3]),
|
||||||
|
width: Number(match[4]),
|
||||||
|
height: Number(match[5]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixSelfLoopSvg(svg) {
|
||||||
|
return svg.replace(
|
||||||
|
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
|
||||||
|
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
|
||||||
|
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
|
||||||
|
if (pts.length < 2) return match;
|
||||||
|
|
||||||
|
const [x0, y0] = pts[0];
|
||||||
|
const [x1, y1] = pts[pts.length - 1];
|
||||||
|
const east = Math.max(x0, x1) + 36;
|
||||||
|
const north = Math.min(y0, y1) - 28;
|
||||||
|
|
||||||
|
const tx = x1 - east;
|
||||||
|
const ty = y1 - north;
|
||||||
|
const len = Math.hypot(tx, ty) || 1;
|
||||||
|
const dx = tx / len;
|
||||||
|
const dy = ty / len;
|
||||||
|
const arrowLen = 9;
|
||||||
|
const arrowHalf = 3.5;
|
||||||
|
const baseX = x1 - dx * arrowLen;
|
||||||
|
const baseY = y1 - dy * arrowLen;
|
||||||
|
const px = -dy * arrowHalf;
|
||||||
|
const py = dx * arrowHalf;
|
||||||
|
|
||||||
|
const fmt = (n) => (Math.round(n * 100) / 100).toString();
|
||||||
|
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
|
||||||
|
const newPointsFmt =
|
||||||
|
`${fmt(x1)},${fmt(y1)} ` +
|
||||||
|
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
|
||||||
|
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
|
||||||
|
`${fmt(x1)},${fmt(y1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<g id="${edgeId}" class="edge">\n` +
|
||||||
|
`<title>${_table}->${_table}</title>\n` +
|
||||||
|
`<g id="${aId}"><a ${aAttrs}>\n` +
|
||||||
|
`<path${pathAttrs} d="${newDFmt}"/>\n` +
|
||||||
|
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
|
||||||
|
`</a>\n` +
|
||||||
|
`</g>\n` +
|
||||||
|
`</g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generating Dashfront Automat Graph…');
|
||||||
|
|
||||||
|
const specsMap = new Map();
|
||||||
|
for (const spec of AUTOMAT_SPECS) {
|
||||||
|
specsMap.set(spec.id, spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Legend pseudo-automat
|
||||||
|
specsMap.set('Legend', {
|
||||||
|
id: 'Legend',
|
||||||
|
title: 'Legend',
|
||||||
|
subtitle: '',
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = EDGE_DEFS.filter((def) => specsMap.has(def.from) && specsMap.has(def.to));
|
||||||
|
|
||||||
|
console.log(`Structured ${specsMap.size - 1} Automats across ${CLUSTERS.length} domain clusters with ${edges.length} relationships`);
|
||||||
|
|
||||||
|
const graphviz = await Graphviz.load();
|
||||||
|
|
||||||
|
// Pass 1: Initial layout to learn coordinates
|
||||||
|
console.log('Layout pass 1/2: computing node positions…');
|
||||||
|
const initialDot = buildDot(specsMap, edges, null, null);
|
||||||
|
const plain = graphviz.layout(initialDot, 'plain', 'dot');
|
||||||
|
const positions = parsePlainPositions(plain);
|
||||||
|
|
||||||
|
// Pass 2: Anchor assignment using learned positions
|
||||||
|
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
|
||||||
|
const anchors = assignEdgeAnchors(edges, positions);
|
||||||
|
const finalDot = buildDot(specsMap, edges, positions, anchors);
|
||||||
|
|
||||||
|
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
|
||||||
|
const fixedSvg = fixSelfLoopSvg(rawSvg);
|
||||||
|
|
||||||
|
// Ensure background color is set on svg tag
|
||||||
|
const finalSvg = fixedSvg.replace(
|
||||||
|
/<svg([^>]*?)>/,
|
||||||
|
`<svg$1 style="background-color:${COLORS.background};">`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(dotOutputPath, finalDot, 'utf8');
|
||||||
|
writeFileSync(outputPath, finalSvg, 'utf8');
|
||||||
|
|
||||||
|
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||||||
|
console.log(`Written ${dotOutputPath}`);
|
||||||
|
console.log(`Written ${outputPath}`);
|
||||||
|
if (dim) {
|
||||||
|
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de" class="notranslate" translate="no">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>QuixPOS * Management Interface</title>
|
<title>QuixPOS * Management Interface</title>
|
||||||
|
|
||||||
|
|||||||
2246
package-lock.json
generated
2246
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -10,50 +10,59 @@
|
|||||||
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
||||||
"cert": "./scripts/generate-cert.sh",
|
"cert": "./scripts/generate-cert.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:https": "VITE_HTTPS=1 vite preview",
|
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||||
"rig:collect": "node test/collect.mjs",
|
"rig:collect": "node test/collect.mjs",
|
||||||
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
|
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
|
||||||
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001"
|
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
|
||||||
|
"automat-graph": "node generate-automat-graph.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.2",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@jsquash/avif": "^2.1.1",
|
"@jsquash/avif": "^2.1.1",
|
||||||
"@jsquash/resize": "^2.1.1",
|
"@jsquash/resize": "^2.1.1",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.5.0",
|
||||||
"@shopify/draggable": "^1.2.1",
|
"@shopify/draggable": "^1.2.1",
|
||||||
"ag-grid-community": "^36.2.0",
|
"@uiw/react-codemirror": "^4.25.12",
|
||||||
"ag-grid-react": "^36.2.0",
|
|
||||||
"dashgui": "file:../dashgui",
|
"dashgui": "file:../dashgui",
|
||||||
"flexsearch": "^0.8.212",
|
"flexsearch": "^0.8.212",
|
||||||
"jsbarcode": "^3.12.3",
|
"jsbarcode": "^3.12.3",
|
||||||
"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.1",
|
||||||
"react-automat": "file:../automat",
|
"react-automat": "file:../automat",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.1",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
"react-window": "^2.3.4",
|
||||||
"react-window": "^2.3.3",
|
"skelehash-wasm": "^0.1.1",
|
||||||
"skelehash-wasm": "^0.1.0",
|
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||||
"@vitejs/plugin-react": "6.1.2",
|
"@vitejs/plugin-react": "6.1.2",
|
||||||
"eslint": "^10.12.0",
|
"eslint": "^10.13.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.13.0",
|
"globals": "^17.13.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vite": "^8.3.2",
|
"vite": "^8.3.4",
|
||||||
"vite-plugin-pwa": "^2.0.0"
|
"vite-plugin-pwa": "^2.0.0"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"canvas@3.2.3": true,
|
"canvas@3.2.3": true,
|
||||||
|
"react-canvas-label-editor@20.0.0": true,
|
||||||
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/App.jsx
74
src/App.jsx
@@ -1,11 +1,16 @@
|
|||||||
import { PureComponent, Suspense } from 'react';
|
import { PureComponent, lazy, Suspense } from 'react';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
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 { RouterProvider } from 'react-router-dom';
|
|
||||||
import authAutomat from './automats/authAutomat';
|
import authAutomat from './automats/authAutomat';
|
||||||
import LoginPage from './components/LoginPage';
|
import LoginPage from './components/LoginPage';
|
||||||
import CompanySetupDialog from './components/CompanySetupDialog';
|
import AppRouter from './appRouter';
|
||||||
import { router } from './appRouter';
|
|
||||||
|
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
|
||||||
|
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||||
|
const SseMonitor = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./devtools/SseMonitor'))
|
||||||
|
: null;
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +35,13 @@ class App extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
|
this.unsubscribe = authAutomat.subscribe(this, [
|
||||||
|
'status',
|
||||||
|
'user',
|
||||||
|
'hasAuth',
|
||||||
|
'hasCompany',
|
||||||
|
'retryNotification',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@@ -38,36 +49,73 @@ class App extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, user } = this.state;
|
const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
|
||||||
|
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
|
||||||
|
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
minHeight: 'var(--df-vh, 100vh)',
|
minHeight: 'var(--df-vh, 100vh)',
|
||||||
bgcolor: 'background.default',
|
bgcolor: 'background.default',
|
||||||
|
gap: 2.5,
|
||||||
|
p: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{retryNotification ? (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
maxWidth: 480,
|
||||||
|
alignItems: 'center',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retryNotification}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status !== 'authed') {
|
if (!isAuthed) {
|
||||||
|
if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
|
||||||
|
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
|
||||||
|
}
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.selectedCompanyId == null) {
|
const isOAuthAuthorize =
|
||||||
return <CompanySetupDialog />;
|
typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<PageFallback />}>
|
<>
|
||||||
<RouterProvider router={router} />
|
<Suspense fallback={<PageFallback />}>
|
||||||
</Suspense>
|
{!isCompanySelected ? (
|
||||||
|
isOAuthAuthorize ? (
|
||||||
|
<OAuthAuthorizePage />
|
||||||
|
) : (
|
||||||
|
<CompanySetupDialog />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<AppRouter />
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
{SseMonitor ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SseMonitor />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/api/account.js
Normal file
24
src/api/account.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company payment accounts.
|
||||||
|
*/
|
||||||
|
export const account = {
|
||||||
|
// #region GET /api/account — list
|
||||||
|
list() {
|
||||||
|
return http('/api/account');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/account — create
|
||||||
|
create(body) {
|
||||||
|
return http('/api/account', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/account/:id — delete
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/account/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
69
src/api/agentprinter.js
Normal file
69
src/api/agentprinter.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const agentprinter = {
|
||||||
|
/**
|
||||||
|
* GET /api/agentprinter
|
||||||
|
* Query: ?printeragentId=<id>
|
||||||
|
* → { agentprinters: Agentprinter[] }
|
||||||
|
*/
|
||||||
|
list(printeragentId) {
|
||||||
|
const url =
|
||||||
|
printeragentId != null
|
||||||
|
? `/api/agentprinter?printeragentId=${encodeURIComponent(printeragentId)}`
|
||||||
|
: '/api/agentprinter';
|
||||||
|
return http(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agentprinter/:id
|
||||||
|
* → { agentprinter: Agentprinter }
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/agentprinter/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/agentprinter/:id
|
||||||
|
* Body: { enabled?: boolean, sort?: number }
|
||||||
|
* → { agentprinter: Agentprinter }
|
||||||
|
*/
|
||||||
|
update(id, patch = {}) {
|
||||||
|
const body = {};
|
||||||
|
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||||
|
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||||
|
body.sort = Number(patch.sort);
|
||||||
|
}
|
||||||
|
return http(`/api/agentprinter/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/agentprinter/order
|
||||||
|
* Body: { printeragentId?: number, agentprinterIds: number[] }
|
||||||
|
* → { agentprinterIds: number[] }
|
||||||
|
*/
|
||||||
|
order({ printeragentId, agentprinterIds }) {
|
||||||
|
const body = {
|
||||||
|
agentprinterIds: Array.isArray(agentprinterIds) ? agentprinterIds.map(Number) : [],
|
||||||
|
};
|
||||||
|
if (printeragentId != null) {
|
||||||
|
body.printeragentId = Number(printeragentId);
|
||||||
|
}
|
||||||
|
return http('/api/agentprinter/order', {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/agentprinter/:id → 204
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/agentprinter/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -29,32 +29,45 @@ export const article = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/article/history
|
* GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
* History of all articles with paging/sorting/filtering
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||||
|
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||||
*/
|
*/
|
||||||
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
// #region GET /api/article/history — getHistory
|
||||||
|
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (startRow != null) params.set('startRow', String(startRow));
|
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
if (endRow != null) params.set('endRow', String(endRow));
|
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
if (offset != null) params.set('offset', String(offset));
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/article/:sku/history
|
* GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
|
* @param {string} sku
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
|
||||||
|
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
|
||||||
*/
|
*/
|
||||||
getBySkuHistory(sku, { limit } = {}) {
|
getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
|
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
|
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** POST /api/article → 201 { article } */
|
/** POST /api/article → 201 { sku } */
|
||||||
create({ categoryId, name, track_stock }) {
|
create({ categoryId, name, track_stock }) {
|
||||||
return http('/api/article', {
|
return http('/api/article', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -70,7 +83,7 @@ export const article = {
|
|||||||
* PATCH /api/article/:sku
|
* PATCH /api/article/:sku
|
||||||
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
|
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
|
||||||
* unitSi must exist for selected company → 404 Unit not found
|
* unitSi must exist for selected company → 404 Unit not found
|
||||||
* → 200 { article } (same detail shape as GET: includes desc, seo)
|
* → 204 (no body; refetch via getBySku)
|
||||||
*/
|
*/
|
||||||
update(sku, body) {
|
update(sku, body) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
||||||
@@ -82,7 +95,7 @@ export const article = {
|
|||||||
/**
|
/**
|
||||||
* PUT /api/article/:sku/track_stock
|
* PUT /api/article/:sku/track_stock
|
||||||
* Body: { track_stock: boolean }
|
* Body: { track_stock: boolean }
|
||||||
* → 200 { article }
|
* → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setTrackStock(sku, body) {
|
setTrackStock(sku, body) {
|
||||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||||
@@ -142,7 +155,7 @@ export const article = {
|
|||||||
* PUT /api/article/:sku/prices
|
* PUT /api/article/:sku/prices
|
||||||
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
|
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
|
||||||
* Replace-all POS customer group prices. Empty prices clears all POS prices.
|
* Replace-all POS customer group prices. Empty prices clears all POS prices.
|
||||||
* → 200 { article } (same shape as GET by SKU)
|
* → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setPrices(sku, prices) {
|
setPrices(sku, prices) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
|
||||||
@@ -153,7 +166,7 @@ export const article = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/article/:sku/weight
|
* PUT /api/article/:sku/weight
|
||||||
* Body: { weight } → 200 { article }
|
* Body: { weight } → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setWeight(sku, body) {
|
setWeight(sku, body) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
|
||||||
@@ -166,7 +179,7 @@ export const article = {
|
|||||||
* PUT /api/article/:sku/brand
|
* PUT /api/article/:sku/brand
|
||||||
* Body: { brandId: number | null } — null clears the brand.
|
* Body: { brandId: number | null } — null clears the brand.
|
||||||
* brandId must belong to the article's company → 404 Article or brand not found.
|
* brandId must belong to the article's company → 404 Article or brand not found.
|
||||||
* → 200 { article } (same shape as GET by SKU)
|
* → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setBrandIdBySku(sku, brandId) {
|
setBrandIdBySku(sku, brandId) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
|
||||||
@@ -179,7 +192,7 @@ export const article = {
|
|||||||
* PUT /api/article/:sku/eans
|
* PUT /api/article/:sku/eans
|
||||||
* Body: { eans: string[] } — replace-all. Empty array clears all.
|
* Body: { eans: string[] } — replace-all. Empty array clears all.
|
||||||
* EANs must be non-empty unique strings → 400 Invalid eans.
|
* EANs must be non-empty unique strings → 400 Invalid eans.
|
||||||
* → 200 { article } (same shape as GET by SKU)
|
* → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setEansBySku(sku, eans) {
|
setEansBySku(sku, eans) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
|
||||||
@@ -192,7 +205,7 @@ export const article = {
|
|||||||
* PUT /api/article/:sku/categories
|
* PUT /api/article/:sku/categories
|
||||||
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
|
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
|
||||||
* Categories must belong to the article's company; ids must be unique.
|
* Categories must belong to the article's company; ids must be unique.
|
||||||
* → 200 { article } (same shape as GET by SKU)
|
* → 204 (no body)
|
||||||
*/
|
*/
|
||||||
setCategories(sku, categoryIds) {
|
setCategories(sku, categoryIds) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
|
||||||
@@ -337,7 +350,7 @@ export const article = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/article/:sku/parent
|
* POST /api/article/:sku/parent
|
||||||
* Body: { isParent: boolean, attributeNameId?: number }
|
* Body: { isParent: boolean, attributeNameId?: number } → 204 (no body)
|
||||||
*/
|
*/
|
||||||
toggleParent(sku, body) {
|
toggleParent(sku, body) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
|
||||||
@@ -346,16 +359,11 @@ export const article = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/article/:sku/children → { children: [...] }
|
|
||||||
*/
|
|
||||||
getChildren(sku) {
|
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/children`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/article/:sku/children
|
* POST /api/article/:sku/children
|
||||||
* Body: { attributeValueId?: number, value?: string, name?: string }
|
* Body: { attributeValueId?: number, value?: string, name?: string }
|
||||||
|
* → 201 { sku }
|
||||||
*/
|
*/
|
||||||
addChild(sku, body) {
|
addChild(sku, body) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
||||||
@@ -375,12 +383,6 @@ export const article = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/article/:sku/articlelist → { items: [...] }
|
|
||||||
*/
|
|
||||||
getArticlelist(sku) {
|
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/article/:sku/articlelist
|
* PUT /api/article/:sku/articlelist
|
||||||
@@ -393,12 +395,6 @@ export const article = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/article/:sku/optionslist → { options: [...] }
|
|
||||||
*/
|
|
||||||
getOptionslist(sku) {
|
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/article/:sku/optionslist
|
* PUT /api/article/:sku/optionslist
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { http } from './http.js';
|
import { http } from './http.js';
|
||||||
|
|
||||||
export const attributevalue = {
|
export const attributevalue = {
|
||||||
/** GET /api/attributename/:attributeId/value → { values: AttributeValue[] } */
|
|
||||||
// #region GET /api/attributename/:attributeId/value — listByAttributeId
|
|
||||||
listByAttributeId(attributeId) {
|
|
||||||
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`);
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
|
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
|
||||||
// #region POST /api/attributename/:attributeId/value — create
|
// #region POST /api/attributename/:attributeId/value — create
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ export const auth = {
|
|||||||
return {
|
return {
|
||||||
user: data.user ?? null,
|
user: data.user ?? null,
|
||||||
googleClientId: data.googleClientId ?? null,
|
googleClientId: data.googleClientId ?? null,
|
||||||
|
schemaVersion: data.schemaVersion ?? null,
|
||||||
|
vhost: Boolean(data.vhost),
|
||||||
|
vhostSettings: data.vhostSettings ?? null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401 || err.status === 403) {
|
if (err.status === 401 || err.status === 403) {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
googleClientId: err.data?.googleClientId ?? null,
|
googleClientId: err.data?.googleClientId ?? null,
|
||||||
|
schemaVersion: err.data?.schemaVersion ?? null,
|
||||||
|
vhost: Boolean(err.data?.vhost),
|
||||||
|
vhostSettings: err.data?.vhostSettings ?? null,
|
||||||
forbidden: err.status === 403,
|
forbidden: err.status === 403,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
};
|
};
|
||||||
|
|||||||
24
src/api/autoinvite.js
Normal file
24
src/api/autoinvite.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending invites for emails that are not registered yet (admin only).
|
||||||
|
*/
|
||||||
|
export const autoinvite = {
|
||||||
|
// #region GET /api/autoinvite — list pending invites
|
||||||
|
list() {
|
||||||
|
return http('/api/autoinvite');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/autoinvite — create an invite
|
||||||
|
create(body) {
|
||||||
|
return http('/api/autoinvite', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/autoinvite/:id — delete an invite
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
51
src/api/backup.js
Normal file
51
src/api/backup.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup API for listing backups, checking service status, inspecting archived VFS files, and recovering VFS files.
|
||||||
|
*/
|
||||||
|
export const backup = {
|
||||||
|
/**
|
||||||
|
* GET /api/backup
|
||||||
|
* Returns: 200 { status: BackupStatus, backups: { counts, hourly, daily, monthly } }
|
||||||
|
*/
|
||||||
|
// #region GET /api/backup — getStatus
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
getStatus() {
|
||||||
|
return http('/api/backup');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backup/:id/vfs
|
||||||
|
* Lists VFS files archived in a specific backup for a company.
|
||||||
|
* @param {string} id Backup identifier (e.g. backup-2026-10-14_02-00-00)
|
||||||
|
* @param {number} [companyId] Optional company ID (defaults to active company)
|
||||||
|
* @returns {Promise<{ backupId: string, companyId: number, files: Array<{ name: string, size: number, mime: string, mtime: string }> }>}
|
||||||
|
*/
|
||||||
|
// #region GET /api/backup/{id}/vfs — listVfs
|
||||||
|
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
|
||||||
|
listVfs(id, companyId) {
|
||||||
|
const q = companyId != null ? `?companyId=${encodeURIComponent(companyId)}` : '';
|
||||||
|
return http(`/api/backup/${encodeURIComponent(id)}/vfs${q}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup/:id/vfs/recover
|
||||||
|
* Safely extracts and restores a VFS file from the specified backup into the active VFS.
|
||||||
|
* @param {string} id Backup identifier
|
||||||
|
* @param {string} path Relative file path within the VFS archive
|
||||||
|
* @param {number} [companyId] Optional company ID
|
||||||
|
* @returns {Promise<{ success: boolean, backupId: string, companyId: number, path: string, sizeBytes: number, mime: string }>}
|
||||||
|
*/
|
||||||
|
// #region POST /api/backup/{id}/vfs/recover — recoverVfs
|
||||||
|
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
|
||||||
|
recoverVfs(id, path, companyId) {
|
||||||
|
return http(`/api/backup/${encodeURIComponent(id)}/vfs/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path, companyId }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
|
|
||||||
49
src/api/carrier.js
Normal file
49
src/api/carrier.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-scoped carriers / Versandarten.
|
||||||
|
* Shape: Carrier = { id, name, sort, hasPackages, isReverse }
|
||||||
|
*/
|
||||||
|
export const carrier = {
|
||||||
|
/** GET /api/carrier → { carriers: Carrier[] } */
|
||||||
|
list() {
|
||||||
|
return http('/api/carrier');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/carrier
|
||||||
|
* Body: { name, sort?, hasPackages?, isReverse? }
|
||||||
|
* → 201 { carrier: Carrier }
|
||||||
|
*/
|
||||||
|
create(body) {
|
||||||
|
return http('/api/carrier', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GET /api/carrier/:id → 200 { carrier: Carrier } */
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/carrier/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/carrier/:id
|
||||||
|
* Body: { name?, sort?, hasPackages?, isReverse? }
|
||||||
|
* → 200 { carrier: Carrier }
|
||||||
|
*/
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/carrier/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/carrier/order
|
||||||
|
* Body: { carrierIds: number[] }
|
||||||
|
* → 200 { carrierIds: number[] }
|
||||||
|
*/
|
||||||
|
order(body) {
|
||||||
|
return http('/api/carrier/order', { method: 'PUT', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** DELETE /api/carrier/:id → 204 · 409 Conflict if in use */
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/carrier/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { http, httpBinary, httpGetBinary } from './http.js';
|
import { http, httpBinary, httpGetBinary } from './http.js';
|
||||||
|
|
||||||
export const category = {
|
export const category = {
|
||||||
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
|
/** GET /api/category/tree → { languageId, tree, articleCount } (company from session) */
|
||||||
// #region GET /api/category/tree — getTree
|
// #region GET /api/category/tree — getTree
|
||||||
getTree() {
|
getTree() {
|
||||||
return http('/api/category/tree');
|
return http('/api/category/tree');
|
||||||
@@ -125,6 +125,21 @@ export const category = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/category/:id/image/generate
|
||||||
|
* Body (optional): { size?: 2 | 3 | 4 }
|
||||||
|
* → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } }
|
||||||
|
*/
|
||||||
|
// #region POST /api/category/:id/image/generate — generateImage
|
||||||
|
generateImage(categoryId, { size } = {}) {
|
||||||
|
return http(`/api/category/${categoryId}/image/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: size != null ? { size } : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/category/export.zip → application/zip (binary download)
|
* GET /api/category/export.zip → application/zip (binary download)
|
||||||
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
|
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
|
||||||
|
|||||||
@@ -193,4 +193,144 @@ export const company = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/company/export
|
||||||
|
* Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks.
|
||||||
|
*
|
||||||
|
* @param {object} [callbacks]
|
||||||
|
* @param {(message: string) => void} [callbacks.onStatus]
|
||||||
|
* @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress]
|
||||||
|
* @param {(chunk: string) => void} [callbacks.onChunk]
|
||||||
|
* @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone]
|
||||||
|
* @param {(error: string) => void} [callbacks.onError]
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {AbortSignal} [options.signal]
|
||||||
|
* @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete
|
||||||
|
* @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>}
|
||||||
|
*/
|
||||||
|
// #region GET /api/company/export — exportCatalog
|
||||||
|
async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) {
|
||||||
|
const response = await fetch('/api/company/export', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errMessage = 'Export failed';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errMessage = error.error || error.message || errMessage;
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse error
|
||||||
|
}
|
||||||
|
throw new Error(errMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const base64Chunks = [];
|
||||||
|
let filename = 'company-export.zip';
|
||||||
|
let donePayload = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() ?? ''; // keep trailing incomplete message
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const lines = part.split('\n');
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) event = line.slice(7).trim();
|
||||||
|
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'status') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
callbacks.onStatus?.(payload.message || data);
|
||||||
|
} catch {
|
||||||
|
callbacks.onStatus?.(data);
|
||||||
|
}
|
||||||
|
} else if (event === 'progress') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
callbacks.onProgress?.(payload);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else if (event === 'chunk') {
|
||||||
|
base64Chunks.push(data);
|
||||||
|
callbacks.onChunk?.(data);
|
||||||
|
} else if (event === 'done') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
donePayload = payload;
|
||||||
|
if (payload.filename) filename = payload.filename;
|
||||||
|
callbacks.onDone?.(payload);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else if (event === 'error') {
|
||||||
|
let errorMsg = 'Export error';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
errorMsg = payload.message || errorMsg;
|
||||||
|
} catch {
|
||||||
|
errorMsg = data || errorMsg;
|
||||||
|
}
|
||||||
|
callbacks.onError?.(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct Blob from base64 chunks
|
||||||
|
const byteArrays = base64Chunks.map((b64) => {
|
||||||
|
const binaryStr = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob(byteArrays, { type: 'application/zip' });
|
||||||
|
|
||||||
|
if (triggerDownload) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
filename,
|
||||||
|
size: blob.size,
|
||||||
|
totalChunks: base64Chunks.length,
|
||||||
|
stats: donePayload?.stats,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);
|
||||||
|
|
||||||
|
|||||||
30
src/api/companyuser.js
Normal file
30
src/api/companyuser.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company members (admin only).
|
||||||
|
*/
|
||||||
|
export const companyuser = {
|
||||||
|
// #region GET /api/companyuser — list members
|
||||||
|
list() {
|
||||||
|
return http('/api/companyuser');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/companyuser — add an existing user by email
|
||||||
|
create(body) {
|
||||||
|
return http('/api/companyuser', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region PATCH /api/companyuser/:id — change role
|
||||||
|
setRole(id, role) {
|
||||||
|
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/companyuser/:id — remove a member
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -10,13 +10,35 @@ import { http, clientIdHeaders } from './http.js';
|
|||||||
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
||||||
*/
|
*/
|
||||||
export const delivery = {
|
export const delivery = {
|
||||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
/**
|
||||||
|
* GET /api/delivery
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number }} [opts]
|
||||||
|
* → { deliveries: DeliverySummary[], total?: number, unfilteredTotal?: number, offset?: number, limit?: number }
|
||||||
|
*/
|
||||||
// #region GET /api/delivery — list
|
// #region GET /api/delivery — list
|
||||||
list() {
|
list(opts) {
|
||||||
|
if (opts && typeof opts === 'object') {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(opts.offset) || 0))));
|
||||||
|
if (opts.limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(opts.limit) || 50))));
|
||||||
|
if (opts.search && String(opts.search).trim()) params.set('search', String(opts.search).trim());
|
||||||
|
if (opts.onlyUnbooked === true || opts.onlyUnbooked === 'true') params.set('onlyUnbooked', 'true');
|
||||||
|
if (opts.supplierId != null) params.set('supplierId', String(opts.supplierId));
|
||||||
|
if (opts.seekMonths != null && Number(opts.seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(opts.seekMonths))));
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(`/api/delivery${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
return http('/api/delivery');
|
return http('/api/delivery');
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/** POST /api/delivery/rebuild-summaries → { ok: boolean, companyId: number } */
|
||||||
|
rebuildSummaries() {
|
||||||
|
return http('/api/delivery/rebuild-summaries', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
/** GET /api/delivery/:id → { delivery: Delivery } */
|
/** GET /api/delivery/:id → { delivery: Delivery } */
|
||||||
// #region GET /api/delivery/:id — get
|
// #region GET /api/delivery/:id — get
|
||||||
get(id) {
|
get(id) {
|
||||||
@@ -85,81 +107,26 @@ export const delivery = {
|
|||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
* DELETE /api/delivery/:id/file/:fileId
|
||||||
* Accepts PDF, TXT or CSV document (max 50 MB)
|
* Deletes delivery file attachment.
|
||||||
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
|
* @param {number|string} id Delivery ID
|
||||||
|
* @param {number|string} fileId File ID
|
||||||
|
* → 204 void
|
||||||
*/
|
*/
|
||||||
// #region POST /api/delivery/parse — parseSse
|
// #region DELETE /api/delivery/:id/file/:fileId — deleteFile
|
||||||
async *parseSse(file, { signal } = {}) {
|
deleteFile(id, fileId) {
|
||||||
const formData = new FormData();
|
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||||
formData.append('file', file, file.name);
|
},
|
||||||
const res = await fetch('/api/delivery/parse', {
|
// #endregion
|
||||||
method: 'POST',
|
|
||||||
headers: { ...clientIdHeaders() },
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const data = await res.json();
|
|
||||||
message = data.error || data.message || message;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buf = '';
|
|
||||||
let currentEvent = 'message';
|
|
||||||
|
|
||||||
const onAbort = () => {
|
/**
|
||||||
try {
|
* POST /api/delivery/:id/pdf
|
||||||
reader.cancel();
|
* Generates the "Wareneingang" PDF and stores it as a manifest attachment.
|
||||||
} catch { /* ignore */ }
|
* → 201 { attachment, fileUrl }
|
||||||
};
|
*/
|
||||||
if (signal) {
|
// #region POST /api/delivery/:id/pdf — createPdf
|
||||||
if (signal.aborted) {
|
createPdf(id) {
|
||||||
onAbort();
|
return http(`/api/delivery/${id}/pdf`, { method: 'POST' });
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buf.split('\n');
|
|
||||||
buf = lines.pop();
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
currentEvent = line.slice(6).trim();
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
const raw = line.slice(5).trim();
|
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
||||||
yield { event: currentEvent, data };
|
|
||||||
currentEvent = 'message';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (signal?.aborted || err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { http } from './http.js';
|
|||||||
|
|
||||||
export const globalHistory = {
|
export const globalHistory = {
|
||||||
/**
|
/**
|
||||||
* GET /api/history
|
* GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
* Global system/entity history with paging/sorting/filtering
|
* Global system/entity history with chunk paging and search/seek filtering
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||||
|
* → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||||
*/
|
*/
|
||||||
// #region GET /api/history — list
|
// #region GET /api/history — list
|
||||||
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (startRow != null) params.set('startRow', String(startRow));
|
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
if (endRow != null) params.set('endRow', String(endRow));
|
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
if (offset != null) params.set('offset', String(offset));
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
|
|||||||
19
src/api/health.js
Normal file
19
src/api/health.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health API for backend latency samples.
|
||||||
|
*/
|
||||||
|
export const health = {
|
||||||
|
/**
|
||||||
|
* GET /api/health/latency?range=3h|all
|
||||||
|
* Returns: 200 { raw: Point[] } for '3h', { raw, avg, max } for 'all'.
|
||||||
|
* Point = { timestamp: epoch ms, value: latency ms }, sorted oldest first.
|
||||||
|
* @param {'3h'|'all'} [range='3h']
|
||||||
|
*/
|
||||||
|
// #region GET /api/health/latency — getLatency
|
||||||
|
// MARK: calls GET /api/health/latency from ./api/index.js
|
||||||
|
getLatency(range = '3h') {
|
||||||
|
return http(`/api/health/latency?range=${encodeURIComponent(range)}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ export { attributename } from './attributename.js';
|
|||||||
export { attributevalue } from './attributevalue.js';
|
export { attributevalue } from './attributevalue.js';
|
||||||
export { brand } from './brand.js';
|
export { brand } from './brand.js';
|
||||||
export { category } from './category.js';
|
export { category } from './category.js';
|
||||||
export { company } from './company.js';
|
export { company, exportCompanyCatalog } from './company.js';
|
||||||
export { customergroup } from './customergroup.js';
|
export { customergroup } from './customergroup.js';
|
||||||
export { delivery } from './delivery.js';
|
export { delivery } from './delivery.js';
|
||||||
export { entity } from './entity.js';
|
export { entity } from './entity.js';
|
||||||
@@ -15,8 +15,12 @@ export { number } from './number.js';
|
|||||||
export { order } from './order.js';
|
export { order } from './order.js';
|
||||||
export { posterminal } from './posterminal.js';
|
export { posterminal } from './posterminal.js';
|
||||||
export { printer } from './printer.js';
|
export { printer } from './printer.js';
|
||||||
|
export { printeragent } from './printeragent.js';
|
||||||
|
export { agentprinter } from './agentprinter.js';
|
||||||
|
export { printerjob } from './printerjob.js';
|
||||||
export { propertyname } from './propertyname.js';
|
export { propertyname } from './propertyname.js';
|
||||||
export { quote } from './quote.js';
|
export { quote } from './quote.js';
|
||||||
|
export { retoure } from './retoure.js';
|
||||||
export { setting } from './setting.js';
|
export { setting } from './setting.js';
|
||||||
export { serper } from './serper.js';
|
export { serper } from './serper.js';
|
||||||
export { supplier } from './supplier.js';
|
export { supplier } from './supplier.js';
|
||||||
@@ -26,3 +30,13 @@ export { vfs } from './vfs.js';
|
|||||||
export { warehouse } from './warehouse.js';
|
export { warehouse } from './warehouse.js';
|
||||||
export { stock } from './stock.js';
|
export { stock } from './stock.js';
|
||||||
export { globalHistory } from './globalHistory.js';
|
export { globalHistory } from './globalHistory.js';
|
||||||
|
export { shipment } from './shipment.js';
|
||||||
|
export { shipping } from './shipping.js';
|
||||||
|
export { payment } from './payment.js';
|
||||||
|
export { account } from './account.js';
|
||||||
|
export { companyuser } from './companyuser.js';
|
||||||
|
export { autoinvite } from './autoinvite.js';
|
||||||
|
export { mcp } from './mcp.js';
|
||||||
|
export { backup } from './backup.js';
|
||||||
|
export { carrier } from './carrier.js';
|
||||||
|
export { health } from './health.js';
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { http } from './http.js';
|
|||||||
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
||||||
*/
|
*/
|
||||||
export const labeltemplate = {
|
export const labeltemplate = {
|
||||||
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
|
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
|
||||||
// #region GET /api/labeltemplate — list
|
// #region GET /api/labeltemplate — list
|
||||||
list() {
|
list(params) {
|
||||||
return http('/api/labeltemplate');
|
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
|
||||||
|
return http(`/api/labeltemplate${search}`);
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -48,5 +49,23 @@ export const labeltemplate = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
|
||||||
|
* @param {number|object} idOrBody
|
||||||
|
* @param {object} [maybeBody]
|
||||||
|
* → 200 { success: true, printerjob?: PrinterJob }
|
||||||
|
*/
|
||||||
|
// #region POST /api/labeltemplate/testprint — testprint
|
||||||
|
testprint(idOrBody, maybeBody) {
|
||||||
|
if (typeof idOrBody === 'object' && idOrBody !== null) {
|
||||||
|
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
|
||||||
|
}
|
||||||
|
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: maybeBody || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// thumb is returned inline as a base64 string in list/get responses
|
// thumb is returned inline as a base64 string in list/get responses
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,28 @@ import { http } from './http.js';
|
|||||||
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||||
*/
|
*/
|
||||||
export const ledger = {
|
export const ledger = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ledger
|
* GET /api/ledger/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
|
* Append-only ledger audit history with chunk paging, search, and seek filtering
|
||||||
* → 201 { ledger: LedgerEntry }
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object, sku?: string, transactionType?: string }} [params]
|
||||||
|
* → { ledger: LedgerEntry[], history?: LedgerEntry[], total: number, unfilteredTotal?: number }
|
||||||
*/
|
*/
|
||||||
// #region POST /api/ledger — add
|
// #region GET /api/ledger/history — getHistory
|
||||||
add(body) {
|
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel, sku, transactionType } = {}) {
|
||||||
return http('/api/ledger', { method: 'POST', body });
|
const params = new URLSearchParams();
|
||||||
|
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
|
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
|
if (sku != null) params.set('sku', String(sku));
|
||||||
|
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||||
|
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||||
|
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(`/api/ledger/history${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -29,31 +43,18 @@ export const ledger = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/ledger/sku/:sku
|
|
||||||
* Query: ?transaction_type=...
|
|
||||||
* → 200 { ledger: LedgerEntry[] }
|
|
||||||
*/
|
|
||||||
// #region GET /api/ledger/sku/:sku — listBySku
|
|
||||||
listBySku(sku, transactionType) {
|
|
||||||
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
|
|
||||||
return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`);
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ledger
|
* GET /api/ledger
|
||||||
* Query: { manifestarticle_id?, sku?, transaction_type? }
|
* Query: { manifestarticleId?, sku?, transactionType? }
|
||||||
* → 200 { ledger: LedgerEntry[] }
|
* → 200 { ledger: LedgerEntry[] }
|
||||||
*/
|
*/
|
||||||
// #region GET /api/ledger — list
|
// #region GET /api/ledger — list
|
||||||
list(query) {
|
list({ manifestarticleId, sku, transactionType } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
|
if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
|
||||||
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
|
if (sku != null) params.set('sku', String(sku));
|
||||||
if (query?.sku != null) params.set('sku', query.sku);
|
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||||
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
|
|
||||||
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
|||||||
40
src/api/mcp.js
Normal file
40
src/api/mcp.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const mcp = {
|
||||||
|
// #region GET /api/mcp/authorize — getAuthorize
|
||||||
|
// MARK: calls GET /api/mcp/authorize
|
||||||
|
async getAuthorize(searchParams) {
|
||||||
|
const query = typeof searchParams === 'string'
|
||||||
|
? (searchParams.startsWith('?') ? searchParams.slice(1) : searchParams)
|
||||||
|
: new URLSearchParams(searchParams).toString();
|
||||||
|
const url = query ? `/api/mcp/authorize?${query}` : '/api/mcp/authorize';
|
||||||
|
return await http(url);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/mcp/authorize — postAuthorize
|
||||||
|
// MARK: calls POST /api/mcp/authorize
|
||||||
|
async postAuthorize(payload) {
|
||||||
|
return await http('/api/mcp/authorize', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/mcp/authorizations — listAuthorizations
|
||||||
|
// MARK: calls GET /api/mcp/authorizations
|
||||||
|
async listAuthorizations() {
|
||||||
|
return await http('/api/mcp/authorizations');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/mcp/authorizations/:clientId — revokeAuthorization
|
||||||
|
// MARK: calls DELETE /api/mcp/authorizations/:clientId
|
||||||
|
async revokeAuthorization(clientId) {
|
||||||
|
return await http(`/api/mcp/authorizations/${encodeURIComponent(clientId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -7,15 +7,22 @@ import { http } from './http.js';
|
|||||||
*/
|
*/
|
||||||
export const order = {
|
export const order = {
|
||||||
/**
|
/**
|
||||||
* GET /api/order?offset=0&limit=20
|
* GET /api/order?offset=0&limit=50&search=...&onlyUnpaid=true&onlyUndelivered=true&seekMonths=-1
|
||||||
* @param {{ offset?: number, limit?: number }} [params]
|
* @param {{ offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number }} [params]
|
||||||
* → { orders: Order[], total: number, offset: number, limit: number }
|
* → { orders: Order[], total: number, offset: number, limit: number }
|
||||||
*/
|
*/
|
||||||
// #region GET /api/order — list
|
// #region GET /api/order — list
|
||||||
list({ offset = 0, limit = 20 } = {}) {
|
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (offset != null) params.set('offset', String(offset));
|
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
|
if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true');
|
||||||
|
if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true');
|
||||||
|
if (entityId != null) params.set('entityId', String(entityId));
|
||||||
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/order${qs ? `?${qs}` : ''}`);
|
return http(`/api/order${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
@@ -56,4 +63,42 @@ export const order = {
|
|||||||
return http(`/api/order/${id}`, { method: 'DELETE' });
|
return http(`/api/order/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/order/:id/file/:fileId
|
||||||
|
* Deletes order file attachment.
|
||||||
|
* @param {number|string} id Order ID
|
||||||
|
* @param {number|string} fileId File ID
|
||||||
|
* → 204 void
|
||||||
|
*/
|
||||||
|
// #region DELETE /api/order/:id/file/:fileId — deleteFile
|
||||||
|
deleteFile(id, fileId) {
|
||||||
|
return http(`/api/order/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/:id/pdf
|
||||||
|
* Generates and attaches order confirmation PDF.
|
||||||
|
* @param {number|string} id Order ID
|
||||||
|
* → 201 { attachment: Attachment, fileUrl: string }
|
||||||
|
*/
|
||||||
|
// #region POST /api/order/:id/pdf — createPdf
|
||||||
|
createPdf(id) {
|
||||||
|
return http(`/api/order/${id}/pdf`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/:id/shipment
|
||||||
|
* Creates shipment from order.
|
||||||
|
* @param {number|string} id Order ID
|
||||||
|
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }> }
|
||||||
|
* → 201 { shipment: Shipment }
|
||||||
|
*/
|
||||||
|
// #region POST /api/order/:id/shipment — createShipment
|
||||||
|
createShipment(id, body = {}) {
|
||||||
|
return http(`/api/order/${id}/shipment`, { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
33
src/api/payment.js
Normal file
33
src/api/payment.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payments linked to orders (manifests).
|
||||||
|
*/
|
||||||
|
export const payment = {
|
||||||
|
// #region GET /api/order/:id/payment — listByOrder
|
||||||
|
listByOrder(orderId) {
|
||||||
|
return http(`/api/order/${orderId}/payment`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/order/:id/payment — create
|
||||||
|
create(orderIdOrBody, maybeBody) {
|
||||||
|
if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
|
||||||
|
return http('/api/payment', { method: 'POST', body: orderIdOrBody });
|
||||||
|
}
|
||||||
|
return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/payment — register
|
||||||
|
register(body) {
|
||||||
|
return http('/api/payment', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/payment/:id — delete
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/payment/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -2,8 +2,8 @@ import { http } from './http.js';
|
|||||||
|
|
||||||
export const posterminal = {
|
export const posterminal = {
|
||||||
/**
|
/**
|
||||||
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
|
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
|
||||||
* List all terminals for the selected company.
|
* List all terminals for the selected company, sorted by sort ASC, id ASC.
|
||||||
*/
|
*/
|
||||||
// #region GET /api/posterminal — list
|
// #region GET /api/posterminal — list
|
||||||
list() {
|
list() {
|
||||||
@@ -13,24 +13,29 @@ export const posterminal = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/posterminal
|
* PUT /api/posterminal
|
||||||
* Body: { name: string }
|
* Body: { name: string, sort?: number, enabled?: boolean, rootCategoryId?: number|null, syncRootArticles?: boolean }
|
||||||
* Creates a new terminal (auto-generates 6-digit pin).
|
* Creates a new terminal (auto-generates 6-digit pin).
|
||||||
* → 200 { posterminal: { id, name, token, pairingpin } }
|
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||||
*/
|
*/
|
||||||
// #region PUT /api/posterminal — create
|
// #region PUT /api/posterminal — create
|
||||||
create({ name }) {
|
create({ name, sort, enabled, rootCategoryId, syncRootArticles } = {}) {
|
||||||
|
const body = { name };
|
||||||
|
if (sort !== undefined) body.sort = Number(sort);
|
||||||
|
if (enabled !== undefined) body.enabled = Boolean(enabled);
|
||||||
|
if (rootCategoryId !== undefined) body.rootCategoryId = rootCategoryId != null ? Number(rootCategoryId) : null;
|
||||||
|
if (syncRootArticles !== undefined) body.syncRootArticles = Boolean(syncRootArticles);
|
||||||
return http('/api/posterminal', {
|
return http('/api/posterminal', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name },
|
body,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/posterminal/:id
|
* PATCH /api/posterminal/:id
|
||||||
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
|
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
|
||||||
* Update posterminal configuration.
|
* Update posterminal configuration.
|
||||||
* → 200 { posterminal }
|
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||||
*/
|
*/
|
||||||
// #region PATCH /api/posterminal/:id — update
|
// #region PATCH /api/posterminal/:id — update
|
||||||
update(terminalId, patch) {
|
update(terminalId, patch) {
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ export const printer = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/** GET /api/printer/:id → { printer: Printer } */
|
|
||||||
// #region GET /api/printer/:id — getById
|
|
||||||
getById(id) {
|
|
||||||
return http(`/api/printer/${id}`);
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/printer
|
* POST /api/printer
|
||||||
* Body: { address, type, model, serial, sort, desc }
|
* Body: { address, type, model, serial, sort, desc }
|
||||||
@@ -58,4 +51,29 @@ export const printer = {
|
|||||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printer/state
|
||||||
|
* Query: ?serial=, ?agentId=
|
||||||
|
* → { printers: LivePrinter[] }
|
||||||
|
*/
|
||||||
|
// #region GET /api/printer/state — live state
|
||||||
|
state({ serial, agentId } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (serial) params.set('serial', serial);
|
||||||
|
if (agentId != null) params.set('agentId', agentId);
|
||||||
|
const query = params.toString();
|
||||||
|
return http(`/api/printer/state${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printer/:id/state
|
||||||
|
* → { printer: LivePrinter }
|
||||||
|
*/
|
||||||
|
// #region GET /api/printer/:id/state — single printer state
|
||||||
|
printerState(id) {
|
||||||
|
return http(`/api/printer/${id}/state`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
58
src/api/printeragent.js
Normal file
58
src/api/printeragent.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const printeragent = {
|
||||||
|
/**
|
||||||
|
* GET /api/printeragent → { printeragents: Printeragent[] }
|
||||||
|
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
return http('/api/printeragent');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/printeragent
|
||||||
|
* Body: { name: string, sort?: number }
|
||||||
|
* Creates a new printer agent (auto-generates 6-digit pairingpin).
|
||||||
|
* → 200 { printeragent: Printeragent }
|
||||||
|
*/
|
||||||
|
create({ name, sort } = {}) {
|
||||||
|
const body = { name };
|
||||||
|
if (sort !== undefined && sort !== null && sort !== '') {
|
||||||
|
body.sort = Number(sort);
|
||||||
|
}
|
||||||
|
return http('/api/printeragent', {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/printeragent/:id
|
||||||
|
* Body: { name?: string, sort?: number, enabled?: boolean }
|
||||||
|
* Updates name, sort, enabled, or any combination.
|
||||||
|
* → 200 { printeragent: Printeragent }
|
||||||
|
*/
|
||||||
|
update(id, patch = {}) {
|
||||||
|
const body = {};
|
||||||
|
if (patch.name !== undefined) body.name = patch.name;
|
||||||
|
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||||
|
body.sort = Number(patch.sort);
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||||
|
return http(`/api/printeragent/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/printeragent/:id → 204 No Content
|
||||||
|
* Permanently deletes agent.
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/printeragent/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
142
src/api/printerjob.js
Normal file
142
src/api/printerjob.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const printerjob = {
|
||||||
|
/**
|
||||||
|
* POST /api/zpljob
|
||||||
|
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
createZplJob({ agentprinterId, data, copiescount = 1 }) {
|
||||||
|
return http('/api/zpljob', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
data: String(data),
|
||||||
|
copiescount: Number(copiescount) || 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a minimal ZPL test label to a specific agent printer.
|
||||||
|
* @param {number} agentprinterId
|
||||||
|
* @param {{ model?: string }} [options]
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
|
||||||
|
const timeStr = new Date().toLocaleTimeString('de-DE');
|
||||||
|
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
|
||||||
|
const zpl = [
|
||||||
|
'^XA',
|
||||||
|
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
|
||||||
|
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
|
||||||
|
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
|
||||||
|
'^XZ',
|
||||||
|
].join('\n');
|
||||||
|
return this.createZplJob({
|
||||||
|
agentprinterId,
|
||||||
|
data: zpl,
|
||||||
|
copiescount: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printerjob
|
||||||
|
* @param {{ agentprinterId?: number, state?: string }} [query]
|
||||||
|
* → { printerjobs: PrinterJob[] }
|
||||||
|
*/
|
||||||
|
list(query = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
|
||||||
|
if (query.state != null) params.set('state', String(query.state));
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printerjob/:id
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/printerjob/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob
|
||||||
|
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
create({ agentprinterId, type = 'zpl', data }) {
|
||||||
|
return http('/api/printerjob', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob/:id/cancel
|
||||||
|
* → { success: true, printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
cancel(id) {
|
||||||
|
return http(`/api/printerjob/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob/cancel-all
|
||||||
|
* Sends ~JA to clear printer buffer and cancels all queued jobs.
|
||||||
|
* @param {{ agentprinterId?: number }} [payload]
|
||||||
|
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
|
||||||
|
*/
|
||||||
|
async cancelAll({ agentprinterId } = {}) {
|
||||||
|
if (agentprinterId != null) {
|
||||||
|
try {
|
||||||
|
await this.createZplJob({
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
data: '~JA',
|
||||||
|
copiescount: 1,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to send ~JA to printer:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await http('/api/printerjob/cancel-all', {
|
||||||
|
method: 'POST',
|
||||||
|
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 404) {
|
||||||
|
const { printerjobs = [] } = await this.list({ agentprinterId });
|
||||||
|
const cancellable = printerjobs.filter(
|
||||||
|
(j) => j.state === 'pending' || j.state === 'processing',
|
||||||
|
);
|
||||||
|
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
|
||||||
|
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cancelledCount: results.length,
|
||||||
|
jobs: results.map((r) => r.printerjob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/printerjob/:id
|
||||||
|
* → { success: true, deletedJob: PrinterJob }
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/printerjob/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -7,10 +7,23 @@ import { http } from './http.js';
|
|||||||
* LineInput = { sku, amount, net, tax, rebate?, comment? }
|
* LineInput = { sku, amount, net, tax, rebate?, comment? }
|
||||||
*/
|
*/
|
||||||
export const quote = {
|
export const quote = {
|
||||||
/** GET /api/quote → { quotes: QuoteSummary[] } */
|
/**
|
||||||
|
* GET /api/quote?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
|
||||||
|
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
|
||||||
|
*/
|
||||||
// #region GET /api/quote — list
|
// #region GET /api/quote — list
|
||||||
list() {
|
list({ offset = 0, limit = 50, search, seekMonths, entityId } = {}) {
|
||||||
return http('/api/quote');
|
const params = new URLSearchParams();
|
||||||
|
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
|
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
|
if (entityId != null) params.set('entityId', String(entityId));
|
||||||
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(`/api/quote${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -49,4 +62,43 @@ export const quote = {
|
|||||||
return http(`/api/quote/${id}`, { method: 'DELETE' });
|
return http(`/api/quote/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/quote/:id/pdf
|
||||||
|
* Generates and attaches quote PDF.
|
||||||
|
* @param {number|string} id Quote ID
|
||||||
|
* → 201 { attachment: object, fileUrl: string }
|
||||||
|
*/
|
||||||
|
// #region POST /api/quote/:id/pdf — createPdf
|
||||||
|
createPdf(id) {
|
||||||
|
return http(`/api/quote/${id}/pdf`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/quote/:id/file/:fileId
|
||||||
|
* Deletes quote attachment file.
|
||||||
|
* @param {number|string} id Quote ID
|
||||||
|
* @param {number|string} fileId File ID
|
||||||
|
* → 204 void
|
||||||
|
*/
|
||||||
|
// #region DELETE /api/quote/:id/file/:fileId — deleteFile
|
||||||
|
deleteFile(id, fileId) {
|
||||||
|
return http(`/api/quote/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/quote/:id/order
|
||||||
|
* Converts quote into sales order.
|
||||||
|
* @param {number|string} id Quote ID
|
||||||
|
* @param {object} [body] Optional overrides: { number, date, entityId, externalNumber, lines }
|
||||||
|
* → 201 { order: Order }
|
||||||
|
*/
|
||||||
|
// #region POST /api/quote/:id/order — createOrder
|
||||||
|
createOrder(id, body = {}) {
|
||||||
|
return http(`/api/quote/${id}/order`, { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
187
src/api/retoure.js
Normal file
187
src/api/retoure.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
|
||||||
|
* Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
|
||||||
|
* positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
|
||||||
|
*/
|
||||||
|
export function normalizeRetoureSummary(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const id = row.manifest_id ?? row.id;
|
||||||
|
const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
|
||||||
|
const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
|
||||||
|
const entityId = row.entity_id ?? row.entityId ?? null;
|
||||||
|
const customerName = row.customer_name ?? row.customerName ?? '';
|
||||||
|
const positions = Number(row.positions) || 0;
|
||||||
|
const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
|
||||||
|
const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
|
||||||
|
const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
|
||||||
|
const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
|
||||||
|
const isPaid = Boolean(row.is_paid ?? row.isPaid);
|
||||||
|
const isShipped = Boolean(row.is_shipped ?? row.isShipped);
|
||||||
|
const createdAt = row.created_at ?? row.createdAt ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
manifest_id: id,
|
||||||
|
number: num,
|
||||||
|
retoure_number: num,
|
||||||
|
date: dt,
|
||||||
|
retoure_date: dt,
|
||||||
|
entityId,
|
||||||
|
entity_id: entityId,
|
||||||
|
customerName,
|
||||||
|
customer_name: customerName,
|
||||||
|
positions,
|
||||||
|
grossTotal,
|
||||||
|
gross_total: grossTotal,
|
||||||
|
paidTotal,
|
||||||
|
paid_total: paidTotal,
|
||||||
|
orderedQuantity,
|
||||||
|
ordered_quantity: orderedQuantity,
|
||||||
|
shippedQuantity,
|
||||||
|
shipped_quantity: shippedQuantity,
|
||||||
|
isPaid,
|
||||||
|
is_paid: isPaid,
|
||||||
|
isShipped,
|
||||||
|
is_shipped: isShipped,
|
||||||
|
createdAt,
|
||||||
|
created_at: createdAt,
|
||||||
|
lines: row.lines ?? [],
|
||||||
|
files: row.files ?? [],
|
||||||
|
shipments: row.shipments ?? [],
|
||||||
|
payments: row.payments ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer Returns / Retouren (manifest type = retoure).
|
||||||
|
*/
|
||||||
|
export const retoure = {
|
||||||
|
/**
|
||||||
|
* GET /api/retoure
|
||||||
|
* Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
|
||||||
|
* → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
|
||||||
|
*/
|
||||||
|
// #region GET /api/retoure — list
|
||||||
|
async list({
|
||||||
|
q,
|
||||||
|
search,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
isPaid,
|
||||||
|
isShipped,
|
||||||
|
onlyUnpaid,
|
||||||
|
onlyUndelivered,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
entityId,
|
||||||
|
seekMonths,
|
||||||
|
} = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const query = q || search;
|
||||||
|
if (query && String(query).trim()) {
|
||||||
|
params.set('q', String(query).trim());
|
||||||
|
params.set('search', String(query).trim());
|
||||||
|
}
|
||||||
|
if (from) params.set('from', String(from));
|
||||||
|
if (to) params.set('to', String(to));
|
||||||
|
if (isPaid !== undefined && isPaid !== null) {
|
||||||
|
params.set('isPaid', String(isPaid));
|
||||||
|
} else if (onlyUnpaid === true || onlyUnpaid === 'true') {
|
||||||
|
params.set('isPaid', 'false');
|
||||||
|
}
|
||||||
|
if (isShipped !== undefined && isShipped !== null) {
|
||||||
|
params.set('isShipped', String(isShipped));
|
||||||
|
} else if (onlyUndelivered === true || onlyUndelivered === 'true') {
|
||||||
|
params.set('isShipped', 'false');
|
||||||
|
}
|
||||||
|
if (page != null) params.set('page', String(page));
|
||||||
|
if (pageSize != null) params.set('pageSize', String(pageSize));
|
||||||
|
if (offset != null) params.set('offset', String(offset));
|
||||||
|
if (limit != null) params.set('limit', String(limit));
|
||||||
|
if (entityId != null) params.set('entityId', String(entityId));
|
||||||
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
|
||||||
|
const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
|
||||||
|
const items = rawList.map(normalizeRetoureSummary);
|
||||||
|
const total = Number(res?.total ?? res?.count ?? items.length);
|
||||||
|
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||||
|
|
||||||
|
return {
|
||||||
|
retoures: items,
|
||||||
|
summaries: items,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
unfilteredTotal,
|
||||||
|
raw: res,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/** GET /api/retoure/:id → { retoure: Retoure } */
|
||||||
|
// #region GET /api/retoure/:id — get
|
||||||
|
async get(id) {
|
||||||
|
const res = await http(`/api/retoure/${id}`);
|
||||||
|
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||||
|
return { retoure: res };
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/retoure
|
||||||
|
* Body: { number?, date, entityId?, externalNumber?, lines }
|
||||||
|
* → 201 { retoure: Retoure }
|
||||||
|
*/
|
||||||
|
// #region POST /api/retoure — create
|
||||||
|
async create(body) {
|
||||||
|
const res = await http('/api/retoure', { method: 'POST', body });
|
||||||
|
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||||
|
return { retoure: res };
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/retoure/:id
|
||||||
|
* Body: { number?, date?, entityId?, externalNumber?, lines? }
|
||||||
|
* → 200 { retoure: Retoure }
|
||||||
|
*/
|
||||||
|
// #region PUT /api/retoure/:id — update
|
||||||
|
async update(id, body) {
|
||||||
|
const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
|
||||||
|
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||||
|
return { retoure: res };
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/** DELETE /api/retoure/:id → 204 */
|
||||||
|
// #region DELETE /api/retoure/:id — delete
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/retoure/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/retoure/:id/shipment
|
||||||
|
* Creates return shipment.
|
||||||
|
* Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
|
||||||
|
* → 201 { shipment: Shipment }
|
||||||
|
*/
|
||||||
|
// #region POST /api/retoure/:id/shipment — createShipment
|
||||||
|
createShipment(id, body = {}) {
|
||||||
|
return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
70
src/api/shipment.js
Normal file
70
src/api/shipment.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound shipments (manifest type = shipment).
|
||||||
|
* Manifest number format: <orderNumber><shipmentPrefix><index>, e.g. AU-1001-L-1.
|
||||||
|
*/
|
||||||
|
export const shipment = {
|
||||||
|
// #region GET /api/shipment — list
|
||||||
|
list(params = {}) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.offset) q.set('offset', String(params.offset));
|
||||||
|
if (params.limit != null) q.set('limit', String(params.limit));
|
||||||
|
if (params.search) q.set('search', String(params.search));
|
||||||
|
if (params.state) q.set('state', String(params.state));
|
||||||
|
if (params.orderId != null) q.set('orderId', String(params.orderId));
|
||||||
|
if (params.entityId != null) q.set('entityId', String(params.entityId));
|
||||||
|
if (params.seekMonths != null) q.set('seekMonths', String(params.seekMonths));
|
||||||
|
const qs = q.toString();
|
||||||
|
return http(`/api/shipment${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region PATCH /api/shipment/:id — update
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/shipment/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/order/:id/shipment — listByOrder
|
||||||
|
listByOrder(orderId) {
|
||||||
|
return http(`/api/order/${orderId}/shipment`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/order/:id/shipment — create
|
||||||
|
create(orderId, body) {
|
||||||
|
return http(`/api/order/${orderId}/shipment`, { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/shipment/:id — get
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/shipment/${id}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/shipment/:id — delete
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/shipment/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/shipment/:id/file/:fileId — deleteFile
|
||||||
|
deleteFile(id, fileId) {
|
||||||
|
return http(`/api/shipment/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region POST /api/shipment/:id/pdf — createPdf
|
||||||
|
createPdf(id) {
|
||||||
|
return http(`/api/shipment/${id}/pdf`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/shipment/:id/file/:fileId — getFileUrl
|
||||||
|
getFileUrl(id, fileId) {
|
||||||
|
return `/api/shipment/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}`;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
25
src/api/shipping.js
Normal file
25
src/api/shipping.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages / logistics shipments attached to a shipment manifest.
|
||||||
|
*/
|
||||||
|
export const shipping = {
|
||||||
|
|
||||||
|
// #region POST /api/shipment/:id/shipping — create
|
||||||
|
create(shipmentId, body) {
|
||||||
|
return http(`/api/shipment/${shipmentId}/shipping`, { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region PATCH /api/shipping/:id — update
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/shipping/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region DELETE /api/shipping/:id — delete
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/shipping/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -13,4 +13,24 @@ export const stock = {
|
|||||||
return http('/api/stock/adjust', { method: 'POST', body });
|
return http('/api/stock/adjust', { method: 'POST', body });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stock/accept
|
||||||
|
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { deliveryId, warehouseId? }
|
||||||
|
*/
|
||||||
|
// #region POST /api/stock/accept — accept
|
||||||
|
accept(body) {
|
||||||
|
return http('/api/stock/accept', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stock/ship
|
||||||
|
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { shipmentId, warehouseId? }
|
||||||
|
*/
|
||||||
|
// #region POST /api/stock/ship — ship
|
||||||
|
ship(body) {
|
||||||
|
return http('/api/stock/ship', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export const supplier = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/** GET /api/supplier/:id → { supplier: Supplier } */
|
||||||
|
// #region GET /api/supplier/:id — get
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/supplier/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/supplier
|
* POST /api/supplier
|
||||||
* Body: { name } — empty name ⇒ 400 Name required.
|
* Body: { name } — empty name ⇒ 400 Name required.
|
||||||
|
|||||||
@@ -12,4 +12,10 @@ export const thumb = {
|
|||||||
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/smallthumb/:fullHash — smallUrl
|
||||||
|
smallUrl(fullHash) {
|
||||||
|
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,4 +143,34 @@ export const vfs = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/skills/restore
|
||||||
|
* Restores all system default .SKILL files into the company's VFS (overwrites existing files).
|
||||||
|
* Requires no request body. Emits vfs SSE event.
|
||||||
|
* → 200 { success: true, restored: string[], count: number }
|
||||||
|
*/
|
||||||
|
// #region POST /api/vfs/skills/restore — restoreSkills
|
||||||
|
restoreSkills() {
|
||||||
|
return http('/api/vfs/skills/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/applets/restore
|
||||||
|
* Restores all system default .applet.js example applets into the company's VFS (overwrites existing files).
|
||||||
|
* Requires no request body. Emits vfs SSE event.
|
||||||
|
* → 200 { success: true, restored: string[], count: number }
|
||||||
|
*/
|
||||||
|
// #region POST /api/vfs/applets/restore — restoreApplets
|
||||||
|
restoreApplets() {
|
||||||
|
return http('/api/vfs/applets/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ export const warehouse = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/** GET /api/warehouse/:id → { warehouse: Warehouse } */
|
|
||||||
// #region GET /api/warehouse/:id — getById
|
|
||||||
getById(id) {
|
|
||||||
return http(`/api/warehouse/${id}`);
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/warehouse
|
* POST /api/warehouse
|
||||||
* Body: { name, sort? } — empty name ⇒ 400 Name required.
|
* Body: { name, sort? } — empty name ⇒ 400 Name required.
|
||||||
|
|||||||
@@ -1,36 +1,62 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, createBrowserRouter } from 'react-router-dom';
|
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||||
import AppShell from './components/AppShell';
|
import AppShell from './components/AppShell';
|
||||||
import SectionLayout from './components/SectionLayout';
|
import SectionLayout from './components/SectionLayout';
|
||||||
|
|
||||||
import ArtikelUebersichtPage from './routes/artikel';
|
const ArtikelUebersichtPage = lazy(() => import('./routes/artikel'));
|
||||||
import ArtikelKategorienPage from './routes/artikel/kategorien';
|
const ArtikelKategorienPage = lazy(() => import('./routes/artikel/kategorien'));
|
||||||
import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorien';
|
const RedirectArtikelKategorien = lazy(() => import('./routes/artikel/RedirectArtikelKategorien'));
|
||||||
import ArtikelLagerPage from './routes/artikel/lager';
|
const ArtikelLagerPage = lazy(() => import('./routes/artikel/lager'));
|
||||||
import ArtikelMarkenPage from './routes/artikel/marken';
|
const ArtikelMarkenPage = lazy(() => import('./routes/artikel/marken'));
|
||||||
import ArtikelVorlagenPage from './routes/artikel/vorlagen';
|
const ArtikelVorlagenPage = lazy(() => import('./routes/artikel/vorlagen'));
|
||||||
import ArtikelMerkmalePage from './routes/artikel/merkmale';
|
const ArtikelMerkmalePage = lazy(() => import('./routes/artikel/merkmale'));
|
||||||
import ArtikelSkuPage from './routes/artikel/_sku';
|
const ArtikelSkuPage = lazy(() => import('./routes/artikel/_sku'));
|
||||||
import LieferungListePage from './routes/lieferung';
|
const LieferungListePage = lazy(() => import('./routes/lieferung'));
|
||||||
import LieferungNeuPage from './routes/lieferung/neu';
|
const LieferungNeuPage = lazy(() => import('./routes/lieferung/neu'));
|
||||||
import LieferungLieferantenPage from './routes/lieferung/lieferanten';
|
const LieferungLieferantenPage = lazy(() => import('./routes/lieferung/lieferanten'));
|
||||||
import LieferungEditPage from './routes/lieferung/_deliveryId';
|
const LieferungEditPage = lazy(() => import('./routes/lieferung/_deliveryId'));
|
||||||
import AuftragAngebotPage from './routes/auftrag';
|
const AuftragAngebotPage = lazy(() => import('./routes/auftrag'));
|
||||||
import AuftragAngebotNeuPage from './routes/auftrag/neu';
|
const AuftragAngebotNeuPage = lazy(() => import('./routes/auftrag/neuesangebot'));
|
||||||
import AuftragAngebotEditPage from './routes/auftrag/_quoteId';
|
const AuftragAngebotEditPage = lazy(() => import('./routes/auftrag/_quoteId'));
|
||||||
import AuftragBestellungPage from './routes/auftrag/bestellung';
|
const AuftragBestellungPage = lazy(() => import('./routes/auftrag/bestellung'));
|
||||||
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
|
const AuftragBestellungNeuPage = lazy(() => import('./routes/auftrag/bestellung/neu'));
|
||||||
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
|
const AuftragBestellungEditPage = lazy(() => import('./routes/auftrag/bestellung/_orderId'));
|
||||||
import KundeListePage from './routes/kunde';
|
const AuftragAusliefernPage = lazy(() => import('./routes/auftrag/ausliefern'));
|
||||||
import KundeNeuPage from './routes/kunde/neu';
|
const AuftragRetourePage = lazy(() => import('./routes/auftrag/retoure'));
|
||||||
import KundeGruppenPage from './routes/kunde/gruppen';
|
const AuftragRetoureNeuPage = lazy(() => import('./routes/auftrag/retoure/neu'));
|
||||||
import KundeEditPage from './routes/kunde/_entityId';
|
const AuftragRetoureEditPage = lazy(() => import('./routes/auftrag/retoure/_retoureId'));
|
||||||
import StatisticsPage from './routes/infos/StatisticsPage';
|
const AuftragZahlungenPage = lazy(() => import('./routes/auftrag/zahlungen'));
|
||||||
|
const AuftragVersandartenPage = lazy(() => import('./routes/auftrag/versandarten'));
|
||||||
|
|
||||||
|
const KundeListePage = lazy(() => import('./routes/kunde'));
|
||||||
|
const KundeNeuPage = lazy(() => import('./routes/kunde/neu'));
|
||||||
|
const KundeGruppenPage = lazy(() => import('./routes/kunde/gruppen'));
|
||||||
|
const KundeEditPage = lazy(() => import('./routes/kunde/_entityId'));
|
||||||
|
const StatisticsPage = lazy(() => import('./routes/infos/StatisticsPage'));
|
||||||
|
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||||
|
|
||||||
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
|
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
|
||||||
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
|
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
|
||||||
|
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
|
||||||
|
const CompanyAutomatDebugPage = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./routes/debug/CompanyAutomatPage'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function LoginRedirect() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const returnTo = params.get('returnTo');
|
||||||
|
return <Navigate to={returnTo || '/artikel'} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/oauth/authorize',
|
||||||
|
element: <OAuthAuthorizePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginRedirect />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
@@ -53,6 +79,7 @@ const routes = [
|
|||||||
{ index: true, element: <ArticleHistoryPage /> },
|
{ index: true, element: <ArticleHistoryPage /> },
|
||||||
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
|
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
|
||||||
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
|
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
|
||||||
|
{ path: 'ledgerhistory', element: <LedgerHistoryPage /> },
|
||||||
{ path: 'statistics', element: <StatisticsPage /> },
|
{ path: 'statistics', element: <StatisticsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -78,11 +105,22 @@ const routes = [
|
|||||||
path: 'auftrag',
|
path: 'auftrag',
|
||||||
element: <SectionLayout sectionPath="/auftrag" />,
|
element: <SectionLayout sectionPath="/auftrag" />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <AuftragAngebotPage /> },
|
{ index: true, element: <AuftragBestellungPage /> },
|
||||||
{ path: 'neu', element: <AuftragAngebotNeuPage /> },
|
{ path: 'angebot', element: <AuftragAngebotPage /> },
|
||||||
{ path: 'bestellung', element: <AuftragBestellungPage /> },
|
{ path: 'angebot/:quoteId', element: <AuftragAngebotEditPage /> },
|
||||||
|
{ path: 'neuesangebot', element: <AuftragAngebotNeuPage /> },
|
||||||
|
{ path: 'angebot/neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
|
||||||
|
{ path: 'neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
|
||||||
|
{ path: 'bestellung', element: <Navigate to="/auftrag" replace /> },
|
||||||
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
||||||
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
||||||
|
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
||||||
|
{ path: 'retoure', element: <AuftragRetourePage /> },
|
||||||
|
{ path: 'retoure/neu', element: <AuftragRetoureNeuPage /> },
|
||||||
|
{ path: 'retoure/:retoureId', element: <AuftragRetoureEditPage /> },
|
||||||
|
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
||||||
|
{ path: 'versandarten', element: <AuftragVersandartenPage /> },
|
||||||
|
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
|
||||||
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -96,6 +134,19 @@ const routes = [
|
|||||||
{ path: ':entityId', element: <KundeEditPage /> },
|
{ path: ':entityId', element: <KundeEditPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(import.meta.env.DEV
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
path: 'debug',
|
||||||
|
element: <SectionLayout sectionPath="/debug" />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <CompanyAutomatDebugPage /> },
|
||||||
|
{ path: 'company', element: <CompanyAutomatDebugPage /> },
|
||||||
|
{ path: 'companyautomat', element: <CompanyAutomatDebugPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ path: '*', element: <Navigate to="/artikel" replace /> },
|
{ path: '*', element: <Navigate to="/artikel" replace /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -108,3 +159,7 @@ export const router =
|
|||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.data.router = router;
|
import.meta.hot.data.router = router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AppRouter() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
|||||||
15
src/assets/printers/index.js
Normal file
15
src/assets/printers/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import zd41x from './zd41x.png';
|
||||||
|
|
||||||
|
// Library of printer images. Entries are matched in order against the
|
||||||
|
// printer model (as reported by the agent), case-insensitively.
|
||||||
|
const PRINTER_IMAGES = [
|
||||||
|
{ pattern: /^ZD41/i, image: zd41x }, // ZD410, ZD411, ZD410-200dpi, ...
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPrinterImage(model) {
|
||||||
|
if (!model) return null;
|
||||||
|
const entry = PRINTER_IMAGES.find(({ pattern }) => pattern.test(String(model).trim()));
|
||||||
|
return entry?.image ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PRINTER_IMAGES;
|
||||||
BIN
src/assets/printers/zd41x.png
Normal file
BIN
src/assets/printers/zd41x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -11,7 +11,7 @@
|
|||||||
* history or supplier details of a specific article.
|
* history or supplier details of a specific article.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { KeyedAutomat } from 'react-automat';
|
import { Automat, KeyedAutomat } from 'react-automat';
|
||||||
import { article as articleApi } from '../api';
|
import { article as articleApi } from '../api';
|
||||||
import { requireArray, requireObject } from '../utils/apiShape';
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
|
|
||||||
@@ -34,26 +34,73 @@ import { requireArray, requireObject } from '../utils/apiShape';
|
|||||||
* @property {number|null} taxRate Tax rate in percent.
|
* @property {number|null} taxRate Tax rate in percent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-SKU article change history (`GET /api/article/{sku}/history`).
|
* Registry of SKU-specific chunk-list automats for article history.
|
||||||
*
|
* @type {Map<string, import('react-automat').Automat>}
|
||||||
* The backend returns the full list; pagination happens client-side in
|
|
||||||
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
|
|
||||||
*
|
|
||||||
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
|
|
||||||
*/
|
*/
|
||||||
export const articleHistoryAutomat = new KeyedAutomat(
|
const skuHistoryAutomats = new Map();
|
||||||
null,
|
|
||||||
{},
|
/**
|
||||||
{
|
* Gets or creates the chunk-list automat for a specific article's history.
|
||||||
name: 'articleHistory',
|
*
|
||||||
loader: async (sku) => {
|
* @param {string|number} sku Article SKU.
|
||||||
const res = await articleApi.getBySkuHistory(String(sku));
|
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
|
||||||
requireObject(res, 'GET /api/article/:sku/history');
|
*/
|
||||||
return requireArray(res.history, 'history');
|
export function getArticleHistoryAutomat(sku) {
|
||||||
},
|
const rawKey = String(sku);
|
||||||
|
const key = rawKey.trim().toLowerCase();
|
||||||
|
let inst = skuHistoryAutomats.get(key);
|
||||||
|
if (!inst) {
|
||||||
|
inst = createChunkListAutomat({
|
||||||
|
name: `articleHistory:${rawKey}`,
|
||||||
|
chunkSize: 50,
|
||||||
|
maxLruChunks: 50,
|
||||||
|
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
|
||||||
|
});
|
||||||
|
skuHistoryAutomats.set(key, inst);
|
||||||
|
}
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
|
||||||
|
*
|
||||||
|
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
|
||||||
|
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
|
||||||
|
*/
|
||||||
|
export const articleHistoryAutomat = {
|
||||||
|
get: getArticleHistoryAutomat,
|
||||||
|
key: getArticleHistoryAutomat,
|
||||||
|
has(sku) {
|
||||||
|
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
|
||||||
},
|
},
|
||||||
);
|
async load(sku) {
|
||||||
|
const inst = getArticleHistoryAutomat(sku);
|
||||||
|
return inst.getItems();
|
||||||
|
},
|
||||||
|
async reload(sku) {
|
||||||
|
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
|
||||||
|
if (inst) {
|
||||||
|
await inst.actions.reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
for (const inst of skuHistoryAutomats.values()) {
|
||||||
|
inst.actions.reset();
|
||||||
|
}
|
||||||
|
skuHistoryAutomats.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset on logout or company change
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
if (auth.status === 'anon') {
|
||||||
|
articleHistoryAutomat.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||||
@@ -68,7 +115,12 @@ export const articleSupplierAutomat = new KeyedAutomat(
|
|||||||
loader: async (sku) => {
|
loader: async (sku) => {
|
||||||
const res = await articleApi.getSuppliers(String(sku));
|
const res = await articleApi.getSuppliers(String(sku));
|
||||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||||
return requireArray(res.suppliers, 'suppliers');
|
const list = requireArray(res.suppliers, 'suppliers');
|
||||||
|
const comp = Automat.get('company');
|
||||||
|
if (comp?.actions?.syncArticleSuppliers) {
|
||||||
|
comp.actions.syncArticleSuppliers(sku, list);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file articleHistoryAutomat.js
|
* @file articleHistoryAutomat.js
|
||||||
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for
|
* @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
|
||||||
* infinite-scrolling article history via `react-virtual-aggridautomat`.
|
* article audit logs and history entries with multi-filter caching and LRU eviction.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
* `selectedCompanyId` changes.
|
||||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||||
|
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
import { article } from '../api';
|
import { article } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
export const articleHistoryGridAutomat = createGridAutomat({
|
export const articleHistoryAutomat = createChunkListAutomat({
|
||||||
name: 'articleHistory',
|
name: 'articleHistory',
|
||||||
blockSize: 50,
|
chunkSize: 50,
|
||||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
maxLruChunks: 50,
|
||||||
try {
|
fetchPage: (params) => article.getHistory(params),
|
||||||
const res = await article.getHistory({
|
|
||||||
startRow,
|
|
||||||
endRow,
|
|
||||||
sortModel,
|
|
||||||
filterModel,
|
|
||||||
});
|
|
||||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
|
||||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
|
||||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
|
||||||
return { rows, lastRow };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to fetch article history (backend route pending):', err);
|
|
||||||
return { rows: [], lastRow: 0 };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload on company switch or auth change
|
// Alias for backwards compatibility
|
||||||
authAutomat.subscribe((auth) => {
|
export const articleHistoryGridAutomat = articleHistoryAutomat;
|
||||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
|
||||||
articleHistoryGridAutomat.actions.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default articleHistoryGridAutomat;
|
export default articleHistoryAutomat;
|
||||||
|
|||||||
@@ -29,12 +29,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
|
import { article as articleApi } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
|
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
import {
|
import {
|
||||||
categoryViews,
|
categoryViews,
|
||||||
collectLoadedCategoryViewData,
|
collectLoadedCategoryViewData,
|
||||||
loadAllCategoryViews,
|
loadAllCategoryViews,
|
||||||
|
invalidateAllCategoryViews,
|
||||||
|
updateArticleInCategoryViews,
|
||||||
|
removeArticleBySku,
|
||||||
} from './categoryViewAutomat';
|
} from './categoryViewAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +92,6 @@ function scheduleRebuild() {
|
|||||||
/**
|
/**
|
||||||
* Normalizes a category-view article row into an index record.
|
* Normalizes a category-view article row into an index record.
|
||||||
*
|
*
|
||||||
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
|
|
||||||
* field stays empty until that endpoint exposes EANs. EAN matching in
|
|
||||||
* `articleSearchAutomat` is therefore not functional yet.
|
|
||||||
*
|
|
||||||
* @param {object} article Raw category-view article row.
|
* @param {object} article Raw category-view article row.
|
||||||
* @param {number|string|null} categoryId Category the article was loaded from.
|
* @param {number|string|null} categoryId Category the article was loaded from.
|
||||||
* @returns {ArticleIndexRecord} Index record.
|
* @returns {ArticleIndexRecord} Index record.
|
||||||
@@ -98,18 +99,56 @@ function scheduleRebuild() {
|
|||||||
function toIndexRecord(article, categoryId) {
|
function toIndexRecord(article, categoryId) {
|
||||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||||
const childCount = Number(article.childCount) || 0;
|
const childCount = Number(article.childCount) || 0;
|
||||||
const price = article.price ?? article.net ?? article.vknetto ?? null;
|
const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null);
|
||||||
|
const warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
|
||||||
|
const trackStock = article.track_stock ?? article.trackStock;
|
||||||
|
const stock = trackStock === false
|
||||||
|
? null
|
||||||
|
: (article.stock != null && !Number.isNaN(Number(article.stock))
|
||||||
|
? Number(article.stock)
|
||||||
|
: (trackStock || warehouses.length > 0
|
||||||
|
? warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
|
||||||
|
: null));
|
||||||
|
const available = article.available != null && !Number.isNaN(Number(article.available))
|
||||||
|
? Number(article.available)
|
||||||
|
: stock;
|
||||||
|
const categories = categoryId != null
|
||||||
|
? String(categoryId)
|
||||||
|
: (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null));
|
||||||
|
|
||||||
|
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||||
|
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? null;
|
||||||
|
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? null;
|
||||||
|
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
|
||||||
|
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
|
||||||
|
|
||||||
|
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...article,
|
...article,
|
||||||
id: article.id,
|
id: article.id,
|
||||||
sku: article.sku,
|
sku: article.sku,
|
||||||
name: article.name,
|
name: article.name,
|
||||||
|
descLength,
|
||||||
|
seo: article.seo ?? null,
|
||||||
price,
|
price,
|
||||||
net: article.net ?? price,
|
net: article.net ?? price,
|
||||||
|
stock,
|
||||||
|
available,
|
||||||
|
warehouses,
|
||||||
|
articlewarehousesummary: Array.isArray(article.articlewarehousesummary)
|
||||||
|
? article.articlewarehousesummary
|
||||||
|
: [],
|
||||||
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
||||||
categories: categoryId != null ? String(categoryId) : null,
|
categories,
|
||||||
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
||||||
childCount,
|
childCount,
|
||||||
|
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
|
||||||
|
parentSku: article.parentSku ?? article.parent_sku ?? null,
|
||||||
|
fullHash,
|
||||||
|
thumbHash,
|
||||||
|
photos,
|
||||||
|
smallThumbBinary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +193,33 @@ export function buildArticleIndex(views) {
|
|||||||
existing.price = p;
|
existing.price = p;
|
||||||
existing.net = article.net ?? p;
|
existing.net = article.net ?? p;
|
||||||
}
|
}
|
||||||
|
if (existing.stock == null && (article.warehouses?.length > 0 || article.stock != null)) {
|
||||||
|
const rec = toIndexRecord(article, categoryId);
|
||||||
|
if (rec.stock != null) {
|
||||||
|
existing.stock = rec.stock;
|
||||||
|
existing.available = rec.available;
|
||||||
|
existing.warehouses = rec.warehouses;
|
||||||
|
existing.articlewarehousesummary = rec.articlewarehousesummary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||||
|
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
|
||||||
|
existing.descLength = incomingDescLen;
|
||||||
|
}
|
||||||
|
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
|
||||||
|
existing.seo = article.seo;
|
||||||
|
}
|
||||||
|
if (Array.isArray(article.eans) && article.eans.length > 0) {
|
||||||
|
const mergedEans = new Set(existing.eans ?? []);
|
||||||
|
for (const e of article.eans) if (e) mergedEans.add(e);
|
||||||
|
existing.eans = Array.from(mergedEans);
|
||||||
|
}
|
||||||
|
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
|
||||||
|
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
|
||||||
|
}
|
||||||
|
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
|
||||||
|
existing.parentSku = article.parentSku ?? article.parent_sku;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +296,32 @@ const actions = {
|
|||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetches and updates a single article in the index and category views by SKU.
|
||||||
|
*
|
||||||
|
* @param {string|number} sku Article SKU.
|
||||||
|
* @returns {Promise<object|null>} Fresh article or null.
|
||||||
|
*/
|
||||||
|
async updateArticleBySku(sku) {
|
||||||
|
const key = String(sku);
|
||||||
|
try {
|
||||||
|
const res = await articleApi.getBySku(key);
|
||||||
|
const article = res?.article;
|
||||||
|
if (article) {
|
||||||
|
updateArticleIndexRecord(article);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 404 || err?.statusCode === 404) {
|
||||||
|
removeArticleBySku(key);
|
||||||
|
const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key);
|
||||||
|
articleIndexAutomat.setState({ items: nextItems });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Reset to idle state (logout / company switch). */
|
/** Reset to idle state (logout / company switch). */
|
||||||
reset() {
|
reset() {
|
||||||
inflightEnsure = null;
|
inflightEnsure = null;
|
||||||
@@ -258,11 +350,90 @@ categoryAutomat.subscribe((state) => {
|
|||||||
scheduleRebuild();
|
scheduleRebuild();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
|
||||||
|
scheduleRebuild();
|
||||||
|
}
|
||||||
|
|
||||||
authAutomat.subscribe((auth) => {
|
authAutomat.subscribe((auth) => {
|
||||||
if (auth.status === 'anon') actions.reset();
|
if (auth.status === 'anon') actions.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upstream Trigger: sseAutomat
|
||||||
|
if (sseAutomat?.actions?.onArticle) {
|
||||||
|
sseAutomat.actions.onArticle(async (sku) => {
|
||||||
|
if (articleIndexAutomat.state.status === 'ready') {
|
||||||
|
await actions.updateArticleBySku(sku);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sseAutomat?.actions?.on) {
|
||||||
|
sseAutomat.actions.on('articles', () => {
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
void actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
sseAutomat.actions.on('warehouse', () => {
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
void actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single article record in the derived index and loaded category views.
|
||||||
|
*
|
||||||
|
* @param {object} article Article detail or row.
|
||||||
|
* @returns {Array<ArticleIndexRecord>|undefined} Updated items array.
|
||||||
|
*/
|
||||||
|
export function updateArticleIndexRecord(article) {
|
||||||
|
if (!article || article.sku == null) return;
|
||||||
|
const key = String(article.sku);
|
||||||
|
updateArticleInCategoryViews(article);
|
||||||
|
|
||||||
|
const items = articleIndexAutomat.state.items;
|
||||||
|
if (!items || !items.length) return;
|
||||||
|
|
||||||
|
const existingIndex = items.findIndex((r) => String(r.sku) === key);
|
||||||
|
const categoryIds = Array.isArray(article.categoryIds)
|
||||||
|
? article.categoryIds.map(String)
|
||||||
|
: [];
|
||||||
|
const primaryCatId = categoryIds[0] ?? null;
|
||||||
|
|
||||||
|
const record = toIndexRecord(article, primaryCatId);
|
||||||
|
if (categoryIds.length > 0) {
|
||||||
|
record.categories = categoryIds.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextItems;
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = items[existingIndex];
|
||||||
|
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||||
|
const hasPhotosProp = 'photos' in article;
|
||||||
|
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||||
|
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||||
|
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||||
|
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||||
|
|
||||||
|
nextItems = [...items];
|
||||||
|
nextItems[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
...record,
|
||||||
|
categories: record.categories ?? existing.categories,
|
||||||
|
brand: article.brand ?? existing.brand,
|
||||||
|
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||||
|
fullHash,
|
||||||
|
thumbHash,
|
||||||
|
photos,
|
||||||
|
smallThumbBinary,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextItems = [...items, record];
|
||||||
|
}
|
||||||
|
|
||||||
|
articleIndexAutomat.setState({ items: nextItems });
|
||||||
|
return nextItems;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patches a single record's name in the derived index (optimistic update).
|
* Patches a single record's name in the derived index (optimistic update).
|
||||||
*
|
*
|
||||||
@@ -280,6 +451,41 @@ export function setArticleIndexName(sku, name) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patches a single record's stock in the derived index (e.g. after detail load or stock adjustment).
|
||||||
|
*
|
||||||
|
* @param {string|number} sku Article SKU.
|
||||||
|
* @param {object} stockData Stock updates.
|
||||||
|
* @param {number|null} [stockData.stock] Total stock.
|
||||||
|
* @param {number|null} [stockData.available] Available stock.
|
||||||
|
* @param {Array<object>} [stockData.warehouses] Warehouse array.
|
||||||
|
* @param {Array<object>} [stockData.articlewarehousesummary] Summary array.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function setArticleIndexStock(sku, { stock, available, warehouses, articlewarehousesummary } = {}) {
|
||||||
|
const items = articleIndexAutomat.state.items;
|
||||||
|
if (!items.length) return;
|
||||||
|
articleIndexAutomat.actions.replace(
|
||||||
|
items.map((row) => {
|
||||||
|
if (String(row.sku) !== String(sku)) return row;
|
||||||
|
const whs = warehouses ?? row.warehouses ?? [];
|
||||||
|
const computedStock = stock !== undefined
|
||||||
|
? stock
|
||||||
|
: (whs.length > 0 ? whs.reduce((sum, w) => sum + (Number(w.stock) || 0), 0) : row.stock);
|
||||||
|
const computedAvailable = available !== undefined
|
||||||
|
? available
|
||||||
|
: (computedStock !== undefined ? computedStock : row.available);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
stock: computedStock,
|
||||||
|
available: computedAvailable,
|
||||||
|
...(warehouses ? { warehouses } : {}),
|
||||||
|
...(articlewarehousesummary ? { articlewarehousesummary } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the derived index records.
|
* Selects the derived index records.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ import { Automat } from 'react-automat';
|
|||||||
import FlexSearch from 'flexsearch';
|
import FlexSearch from 'flexsearch';
|
||||||
import articleIndexAutomat from './articleIndexAutomat';
|
import articleIndexAutomat from './articleIndexAutomat';
|
||||||
import categoryAutomat from './categoryAutomat';
|
import categoryAutomat from './categoryAutomat';
|
||||||
|
import companyAutomat from './companyAutomat';
|
||||||
import { parseFilter } from '../components/article/searchFormat';
|
import { parseFilter } from '../components/article/searchFormat';
|
||||||
|
|
||||||
|
// #region Types & Initial State
|
||||||
|
// MARK: typedefs, initial state, and FlexSearch instance variables
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ArticleSearchResult
|
* @typedef {Object} ArticleSearchResult
|
||||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||||
|
* @property {Record<string,object>} brandFacets Facet counts per brand.
|
||||||
|
* @property {Record<string,object>} supplierFacets Facet counts per supplier.
|
||||||
* @property {number} totalCount Number of matching records.
|
* @property {number} totalCount Number of matching records.
|
||||||
* @property {Array<object>} recordset Sliced matching article index records for the current page.
|
* @property {Array<object>} recordset Sliced matching article index records for the current page.
|
||||||
* @property {number} offset Current 0-based offset.
|
* @property {number} offset Current 0-based offset.
|
||||||
@@ -33,6 +39,8 @@ import { parseFilter } from '../components/article/searchFormat';
|
|||||||
* @property {ArticleSearchResult} result Latest search outcome.
|
* @property {ArticleSearchResult} result Latest search outcome.
|
||||||
* @property {string} target Active search term.
|
* @property {string} target Active search term.
|
||||||
* @property {string} filter Active category filter string.
|
* @property {string} filter Active category filter string.
|
||||||
|
* @property {string} brand Active brand filter string.
|
||||||
|
* @property {string} supplier Active supplier filter string.
|
||||||
* @property {number} offset Current 0-based offset.
|
* @property {number} offset Current 0-based offset.
|
||||||
* @property {number|null} pageSize Current page size.
|
* @property {number|null} pageSize Current page size.
|
||||||
* @property {boolean} loading Whether a search or index load is in flight.
|
* @property {boolean} loading Whether a search or index load is in flight.
|
||||||
@@ -41,6 +49,8 @@ import { parseFilter } from '../components/article/searchFormat';
|
|||||||
/** @type {ArticleSearchResult} */
|
/** @type {ArticleSearchResult} */
|
||||||
const emptyResult = {
|
const emptyResult = {
|
||||||
categoryFacets: {},
|
categoryFacets: {},
|
||||||
|
brandFacets: {},
|
||||||
|
supplierFacets: {},
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
recordset: [],
|
recordset: [],
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -55,6 +65,10 @@ const initialState = {
|
|||||||
result: emptyResult,
|
result: emptyResult,
|
||||||
target: '',
|
target: '',
|
||||||
filter: '',
|
filter: '',
|
||||||
|
brand: '',
|
||||||
|
supplier: '',
|
||||||
|
empty: '',
|
||||||
|
hierarchy: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
pageSize: null,
|
pageSize: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -67,7 +81,51 @@ const { Document, Charset } = FlexSearch;
|
|||||||
let flexDocument = null;
|
let flexDocument = null;
|
||||||
let lastIndexedArticles = null;
|
let lastIndexedArticles = null;
|
||||||
let articleByInternalId = new Map();
|
let articleByInternalId = new Map();
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Helpers
|
||||||
|
|
||||||
|
// #region expandSearchTerms
|
||||||
|
// MARK: expands search strings by splitting camelCase and punctuation
|
||||||
|
/**
|
||||||
|
* Expands a string by splitting camelCase words and punctuation delimiters
|
||||||
|
* into separate words so that sub-words (e.g. "BioBizz" -> "Bio Bizz", "Light-Mix" -> "Light Mix")
|
||||||
|
* can be indexed and searched by prefix or infix.
|
||||||
|
*
|
||||||
|
* @param {string} str Raw text string.
|
||||||
|
* @returns {string} Text with added spaced variants.
|
||||||
|
*/
|
||||||
|
function expandSearchTerms(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const camel = str.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||||||
|
const normalized = camel.replace(/[-_./·+]/g, ' ');
|
||||||
|
if (normalized === str) return str;
|
||||||
|
return `${str} ${normalized}`;
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region extractWords
|
||||||
|
// MARK: extracts normalized word tokens from a string
|
||||||
|
/**
|
||||||
|
* Splits text into individual lowercase word tokens, splitting across
|
||||||
|
* camelCase transitions and punctuation delimiters.
|
||||||
|
*
|
||||||
|
* @param {string} str Raw text.
|
||||||
|
* @returns {Array<string>} Normalized word list.
|
||||||
|
*/
|
||||||
|
function extractWords(str) {
|
||||||
|
if (!str) return [];
|
||||||
|
return str
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[-_./·+]/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region getOrCreateFlexIndex
|
||||||
|
// MARK: constructs or retrieves FlexSearch document index
|
||||||
/**
|
/**
|
||||||
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
|
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
|
||||||
*
|
*
|
||||||
@@ -85,8 +143,8 @@ function getOrCreateFlexIndex(articles) {
|
|||||||
index: [
|
index: [
|
||||||
{ field: 'all', tokenize: 'forward', resolution: 9 },
|
{ field: 'all', tokenize: 'forward', resolution: 9 },
|
||||||
{ field: 'sku', tokenize: 'full', resolution: 9 },
|
{ field: 'sku', tokenize: 'full', resolution: 9 },
|
||||||
{ field: 'name', tokenize: 'forward', resolution: 9 },
|
{ field: 'name', tokenize: 'full', resolution: 9 },
|
||||||
{ field: 'brand', tokenize: 'forward', resolution: 5 },
|
{ field: 'brand', tokenize: 'full', resolution: 5 },
|
||||||
{ field: 'eans', tokenize: 'forward', resolution: 9 },
|
{ field: 'eans', tokenize: 'forward', resolution: 9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -100,8 +158,10 @@ function getOrCreateFlexIndex(articles) {
|
|||||||
const docId = i;
|
const docId = i;
|
||||||
byId.set(docId, article);
|
byId.set(docId, article);
|
||||||
const sku = String(article.sku ?? '');
|
const sku = String(article.sku ?? '');
|
||||||
const name = String(article.name ?? '');
|
const rawName = String(article.name ?? '');
|
||||||
const brand = String(article.brand?.name || article.brandName || '');
|
const rawBrand = String(article.brand?.name || article.brandName || '');
|
||||||
|
const name = expandSearchTerms(rawName);
|
||||||
|
const brand = expandSearchTerms(rawBrand);
|
||||||
const eans = (article.eans ?? []).join(' ');
|
const eans = (article.eans ?? []).join(' ');
|
||||||
const all = `${sku} ${name} ${brand} ${eans}`.trim();
|
const all = `${sku} ${name} ${brand} ${eans}`.trim();
|
||||||
|
|
||||||
@@ -120,7 +180,10 @@ function getOrCreateFlexIndex(articles) {
|
|||||||
articleByInternalId = byId;
|
articleByInternalId = byId;
|
||||||
return { doc, byId };
|
return { doc, byId };
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region queryFlexSearch
|
||||||
|
// MARK: queries FlexSearch document index
|
||||||
/**
|
/**
|
||||||
* Searches the FlexSearch index with the query string.
|
* Searches the FlexSearch index with the query string.
|
||||||
*
|
*
|
||||||
@@ -158,7 +221,10 @@ function queryFlexSearch(query, articles) {
|
|||||||
}
|
}
|
||||||
return matchesList;
|
return matchesList;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region getArticleRelevanceScore
|
||||||
|
// MARK: calculates search relevance score for an article
|
||||||
/**
|
/**
|
||||||
* Calculates a search relevance score for an article record against a query.
|
* Calculates a search relevance score for an article record against a query.
|
||||||
*
|
*
|
||||||
@@ -170,8 +236,10 @@ export function getArticleRelevanceScore(article, query) {
|
|||||||
if (!query) return 0;
|
if (!query) return 0;
|
||||||
const q = String(query).trim().toLowerCase();
|
const q = String(query).trim().toLowerCase();
|
||||||
const sku = String(article.sku ?? '').toLowerCase();
|
const sku = String(article.sku ?? '').toLowerCase();
|
||||||
const name = String(article.name ?? '').toLowerCase();
|
const rawName = String(article.name ?? '');
|
||||||
const brand = String(article.brand?.name || article.brandName || '').toLowerCase();
|
const name = rawName.toLowerCase();
|
||||||
|
const rawBrand = String(article.brand?.name || article.brandName || '');
|
||||||
|
const brand = rawBrand.toLowerCase();
|
||||||
const eans = (article.eans ?? []).map((e) => String(e).toLowerCase());
|
const eans = (article.eans ?? []).map((e) => String(e).toLowerCase());
|
||||||
|
|
||||||
// 1. Exact SKU match
|
// 1. Exact SKU match
|
||||||
@@ -184,8 +252,9 @@ export function getArticleRelevanceScore(article, query) {
|
|||||||
if (name.startsWith(q)) return 3000;
|
if (name.startsWith(q)) return 3000;
|
||||||
// 5. SKU contains query
|
// 5. SKU contains query
|
||||||
if (sku.includes(q)) return 2000;
|
if (sku.includes(q)) return 2000;
|
||||||
|
|
||||||
|
const words = extractWords(rawName);
|
||||||
// 6. Word in name starts with query
|
// 6. Word in name starts with query
|
||||||
const words = name.split(/\s+/);
|
|
||||||
if (words.some((w) => w.startsWith(q))) return 1500;
|
if (words.some((w) => w.startsWith(q))) return 1500;
|
||||||
// 7. Name contains query
|
// 7. Name contains query
|
||||||
if (name.includes(q)) return 1000;
|
if (name.includes(q)) return 1000;
|
||||||
@@ -193,14 +262,41 @@ export function getArticleRelevanceScore(article, query) {
|
|||||||
if (eans.includes(q)) return 800;
|
if (eans.includes(q)) return 800;
|
||||||
// 9. EAN starts with query
|
// 9. EAN starts with query
|
||||||
if (eans.some((e) => e.startsWith(q))) return 600;
|
if (eans.some((e) => e.startsWith(q))) return 600;
|
||||||
// 10. Brand starts with query
|
|
||||||
|
const brandWords = extractWords(rawBrand);
|
||||||
|
// 10. Brand exact match
|
||||||
|
if (brand === q) return 500;
|
||||||
|
// 11. Brand starts with query
|
||||||
if (brand.startsWith(q)) return 400;
|
if (brand.startsWith(q)) return 400;
|
||||||
// 11. Brand contains query
|
// 12. Word in brand starts with query
|
||||||
|
if (brandWords.some((w) => w.startsWith(q))) return 350;
|
||||||
|
// 13. Brand contains query
|
||||||
if (brand.includes(q)) return 200;
|
if (brand.includes(q)) return 200;
|
||||||
|
|
||||||
|
// 14. Multi-word query where all terms match across article fields
|
||||||
|
const qWords = q.split(/\s+/).filter(Boolean);
|
||||||
|
if (qWords.length > 1) {
|
||||||
|
const allMatch = qWords.every((term) =>
|
||||||
|
words.some((w) => w.startsWith(term)) ||
|
||||||
|
name.includes(term) ||
|
||||||
|
brandWords.some((w) => w.startsWith(term)) ||
|
||||||
|
brand.includes(term) ||
|
||||||
|
sku.includes(term)
|
||||||
|
);
|
||||||
|
if (allMatch) {
|
||||||
|
const prefixCount = qWords.filter((term) =>
|
||||||
|
words.some((w) => w.startsWith(term)) || brandWords.some((w) => w.startsWith(term))
|
||||||
|
).length;
|
||||||
|
return 1200 + prefixCount * 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 10;
|
return 10;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region sortArticlesByRelevance
|
||||||
|
// MARK: sorts articles by relevance score, name, and sku
|
||||||
/**
|
/**
|
||||||
* Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU.
|
* Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU.
|
||||||
*
|
*
|
||||||
@@ -225,7 +321,10 @@ export function sortArticlesByRelevance(articles, query) {
|
|||||||
return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de');
|
return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region searchArticlesIndex
|
||||||
|
// MARK: calls articleIndexAutomat.actions.ensureLoaded
|
||||||
/**
|
/**
|
||||||
* Synchronously searches the article index using the search automat token matching and relevance order.
|
* Synchronously searches the article index using the search automat token matching and relevance order.
|
||||||
*
|
*
|
||||||
@@ -242,8 +341,12 @@ export function sortArticlesByRelevance(articles, query) {
|
|||||||
export function searchArticlesIndex(query, options = {}) {
|
export function searchArticlesIndex(query, options = {}) {
|
||||||
const {
|
const {
|
||||||
excludeSkus = [],
|
excludeSkus = [],
|
||||||
|
includeSkus = null,
|
||||||
|
extraArticles = [],
|
||||||
allowParent = false,
|
allowParent = false,
|
||||||
categories = [],
|
categories = [],
|
||||||
|
hierarchy = '',
|
||||||
|
empty = '',
|
||||||
offset = 0,
|
offset = 0,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
limit,
|
limit,
|
||||||
@@ -253,19 +356,83 @@ export function searchArticlesIndex(query, options = {}) {
|
|||||||
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const articles = articleIndexAutomat.state.items ?? [];
|
let articles = articleIndexAutomat.state.items ?? [];
|
||||||
|
if (Array.isArray(extraArticles) && extraArticles.length > 0) {
|
||||||
|
const existingSkus = new Set(articles.map((a) => String(a.sku || '').toLowerCase()));
|
||||||
|
const missing = extraArticles.filter((a) => a.sku && !existingSkus.has(String(a.sku).toLowerCase()));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
articles = [...articles, ...missing];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const q = String(query ?? '').trim();
|
const q = String(query ?? '').trim();
|
||||||
|
|
||||||
const rawMatches = q ? queryFlexSearch(q, articles) : articles;
|
const rawMatches = q ? queryFlexSearch(q, articles) : articles;
|
||||||
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
||||||
|
|
||||||
let pool = rawMatches;
|
let pool = rawMatches;
|
||||||
if (!allowParent) {
|
if (!allowParent && !hierarchy) {
|
||||||
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
||||||
}
|
}
|
||||||
if (excludedSet.size > 0) {
|
if (excludedSet.size > 0) {
|
||||||
pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase()));
|
pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase()));
|
||||||
}
|
}
|
||||||
|
if (includeSkus != null) {
|
||||||
|
const includedSet = new Set((includeSkus ?? []).map((s) => String(s).toLowerCase()));
|
||||||
|
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hierarchy === 'father') {
|
||||||
|
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
|
||||||
|
} else if (hierarchy === 'child') {
|
||||||
|
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilters = parseFilter(empty);
|
||||||
|
if (emptyFilters.length > 0) {
|
||||||
|
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||||
|
pool = pool.filter((row) => {
|
||||||
|
for (const f of emptyFilters) {
|
||||||
|
if (f === 'ean') {
|
||||||
|
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||||
|
(row.ean && String(row.ean).trim());
|
||||||
|
if (hasEan) return false;
|
||||||
|
} else if (f === 'photo') {
|
||||||
|
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||||
|
const hasPhoto = Boolean(
|
||||||
|
row.fullHash ||
|
||||||
|
row.thumbHash ||
|
||||||
|
row.smallThumbBinary ||
|
||||||
|
firstPhoto?.fullHash ||
|
||||||
|
firstPhoto?.thumbHash ||
|
||||||
|
firstPhoto?.smallThumbBinary
|
||||||
|
);
|
||||||
|
if (hasPhoto) return false;
|
||||||
|
} else if (f === 'price') {
|
||||||
|
const p = row.price ?? row.net;
|
||||||
|
const hasPrice = p != null && Number(p) > 0;
|
||||||
|
if (hasPrice) return false;
|
||||||
|
} else if (f === 'brand') {
|
||||||
|
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||||
|
if (hasBrand) return false;
|
||||||
|
} else if (f === 'supplier') {
|
||||||
|
const hasSupplier = Boolean(
|
||||||
|
(row.suppliers && row.suppliers.length > 0) ||
|
||||||
|
row.supplierId != null ||
|
||||||
|
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||||
|
);
|
||||||
|
if (hasSupplier) return false;
|
||||||
|
} else if (f === 'seo') {
|
||||||
|
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||||
|
if (hasSeo) return false;
|
||||||
|
} else if (f === 'desc') {
|
||||||
|
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||||
|
if (hasDesc) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let matchesList = pool;
|
let matchesList = pool;
|
||||||
|
|
||||||
@@ -294,7 +461,10 @@ export function searchArticlesIndex(query, options = {}) {
|
|||||||
|
|
||||||
return recordset;
|
return recordset;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region isRootCategory
|
||||||
|
// MARK: checks if category id or name represents the root category
|
||||||
/**
|
/**
|
||||||
* Determines whether a category ID or name represents the root category.
|
* Determines whether a category ID or name represents the root category.
|
||||||
*
|
*
|
||||||
@@ -312,7 +482,10 @@ function isRootCategory(id, rootCategoryId, metaName) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region getCategoryAndDescendantIds
|
||||||
|
// MARK: recursively collects category id and descendant category ids
|
||||||
/**
|
/**
|
||||||
* Recursively collects categoryId and all its descendant category IDs.
|
* Recursively collects categoryId and all its descendant category IDs.
|
||||||
*
|
*
|
||||||
@@ -390,7 +563,10 @@ export function getCategoryAndDescendantIds(selectedCategories, categoriesMap, f
|
|||||||
|
|
||||||
return allIds;
|
return allIds;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region matchesCategory
|
||||||
|
// MARK: checks whether article matches expanded category ids
|
||||||
/**
|
/**
|
||||||
* Checks whether an article belongs to at least one of the target categories.
|
* Checks whether an article belongs to at least one of the target categories.
|
||||||
*
|
*
|
||||||
@@ -404,7 +580,10 @@ function matchesCategory(row, expandedCategoryIds) {
|
|||||||
const rowCats = String(row.categories).split(',').map((s) => s.trim());
|
const rowCats = String(row.categories).split(',').map((s) => s.trim());
|
||||||
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region buildCategoryFacets
|
||||||
|
// MARK: computes category facet counts from matched articles
|
||||||
/**
|
/**
|
||||||
* Computes facet counts for each category represented in matched articles.
|
* Computes facet counts for each category represented in matched articles.
|
||||||
*
|
*
|
||||||
@@ -449,8 +628,125 @@ function buildCategoryFacets(matchedArticles) {
|
|||||||
|
|
||||||
return facets;
|
return facets;
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region buildBrandFacets
|
||||||
|
// MARK: computes brand facet counts from matched articles
|
||||||
|
/**
|
||||||
|
* Computes facet counts for each brand represented in matched articles.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||||
|
* @returns {Record<string, {id: string, name: string, count: number, brand: object, thumbHash: string|null}>} Brand facets.
|
||||||
|
*/
|
||||||
|
function buildBrandFacets(matchedArticles) {
|
||||||
|
const facets = {};
|
||||||
|
const allBrands = companyAutomat.state?.brands ?? [];
|
||||||
|
const brandById = new Map();
|
||||||
|
const brandByName = new Map();
|
||||||
|
for (const b of allBrands) {
|
||||||
|
if (b.id != null) brandById.set(String(b.id), b);
|
||||||
|
if (b.name) brandByName.set(String(b.name).toLowerCase(), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const article of matchedArticles) {
|
||||||
|
const brandObj = article.brand;
|
||||||
|
const bId = brandObj?.id ?? article.brandId;
|
||||||
|
const bName = brandObj?.name ?? article.brandName;
|
||||||
|
if (!bId && !bName) continue;
|
||||||
|
|
||||||
|
const brandKey = bId != null ? String(bId) : String(bName);
|
||||||
|
const knownBrand = (bId != null ? brandById.get(String(bId)) : null) ||
|
||||||
|
(bName ? brandByName.get(String(bName).toLowerCase()) : null);
|
||||||
|
|
||||||
|
const name = knownBrand?.name || bName || `Marke ${brandKey}`;
|
||||||
|
const id = knownBrand?.id != null ? String(knownBrand.id) : brandKey;
|
||||||
|
const thumbHash = knownBrand?.pics?.[0]?.thumbHash || brandObj?.thumbHash || article.brandThumbHash || null;
|
||||||
|
|
||||||
|
if (!facets[id]) {
|
||||||
|
facets[id] = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
thumbHash,
|
||||||
|
brand: knownBrand || brandObj || { id, name, thumbHash },
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
facets[id].count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return facets;
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region buildSupplierFacets
|
||||||
|
// MARK: computes supplier facet counts from matched articles
|
||||||
|
/**
|
||||||
|
* Computes facet counts for each supplier represented in matched articles.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||||
|
* @returns {Record<string, {id: string, name: string, count: number}>} Supplier facets.
|
||||||
|
*/
|
||||||
|
function buildSupplierFacets(matchedArticles) {
|
||||||
|
const facets = {};
|
||||||
|
const suppliers = companyAutomat.state?.suppliers ?? [];
|
||||||
|
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||||
|
|
||||||
|
const supplierArticleSets = new Map();
|
||||||
|
for (const s of suppliers) {
|
||||||
|
const sId = String(s.id);
|
||||||
|
const list = articlesBySupplier[sId] ?? [];
|
||||||
|
const skuSet = new Set();
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.article?.sku) skuSet.add(String(item.article.sku).toLowerCase());
|
||||||
|
if (item.sku) skuSet.add(String(item.sku).toLowerCase());
|
||||||
|
}
|
||||||
|
supplierArticleSets.set(sId, skuSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const article of matchedArticles) {
|
||||||
|
const artSku = String(article.sku ?? '').toLowerCase();
|
||||||
|
if (!artSku) continue;
|
||||||
|
|
||||||
|
for (const s of suppliers) {
|
||||||
|
const sId = String(s.id);
|
||||||
|
const skuSet = supplierArticleSets.get(sId);
|
||||||
|
if (skuSet && skuSet.has(artSku)) {
|
||||||
|
if (!facets[sId]) {
|
||||||
|
facets[sId] = {
|
||||||
|
id: sId,
|
||||||
|
name: s.name,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
facets[sId].count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return facets;
|
||||||
|
}
|
||||||
|
function getSkusWithSupplier() {
|
||||||
|
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||||
|
const set = new Set();
|
||||||
|
for (const list of Object.values(articlesBySupplier)) {
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
|
||||||
|
if (item.sku) set.add(String(item.sku).toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Actions
|
||||||
const actions = {
|
const actions = {
|
||||||
|
// #region runSearch
|
||||||
|
// MARK: runs in-memory search and derivation synchronously
|
||||||
/**
|
/**
|
||||||
* Executes in-memory search and derivation synchronously.
|
* Executes in-memory search and derivation synchronously.
|
||||||
*
|
*
|
||||||
@@ -461,6 +757,10 @@ const actions = {
|
|||||||
const currentState = articleSearchAutomat.state;
|
const currentState = articleSearchAutomat.state;
|
||||||
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
|
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
|
||||||
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||||
|
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
|
||||||
|
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
|
||||||
|
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
|
||||||
|
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
|
||||||
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||||
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||||
|
|
||||||
@@ -472,8 +772,10 @@ const actions = {
|
|||||||
? queryFlexSearch(query, articles)
|
? queryFlexSearch(query, articles)
|
||||||
: articles;
|
: articles;
|
||||||
|
|
||||||
// Build facets for all text matches (before category filtering)
|
// Build facets for all text matches (before facet filtering)
|
||||||
const categoryFacets = buildCategoryFacets(textMatches);
|
const categoryFacets = buildCategoryFacets(textMatches);
|
||||||
|
const brandFacets = buildBrandFacets(textMatches);
|
||||||
|
const supplierFacets = buildSupplierFacets(textMatches);
|
||||||
|
|
||||||
// Apply category filter if set (expanding to include all child categories)
|
// Apply category filter if set (expanding to include all child categories)
|
||||||
const selectedCategories = parseFilter(filter);
|
const selectedCategories = parseFilter(filter);
|
||||||
@@ -486,11 +788,100 @@ const actions = {
|
|||||||
categoriesMap,
|
categoriesMap,
|
||||||
categoryFacets,
|
categoryFacets,
|
||||||
);
|
);
|
||||||
filteredArticles = textMatches.filter((row) =>
|
filteredArticles = filteredArticles.filter((row) =>
|
||||||
matchesCategory(row, expandedCategoryIds),
|
matchesCategory(row, expandedCategoryIds),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply brand filter if set
|
||||||
|
const selectedBrands = parseFilter(brand);
|
||||||
|
if (selectedBrands.length > 0) {
|
||||||
|
const brandSet = new Set(selectedBrands.map((b) => String(b).trim().toLowerCase()));
|
||||||
|
filteredArticles = filteredArticles.filter((row) => {
|
||||||
|
const bId = row.brand?.id ?? row.brandId;
|
||||||
|
const bName = row.brand?.name ?? row.brandName;
|
||||||
|
if (bId != null && brandSet.has(String(bId).toLowerCase())) return true;
|
||||||
|
if (bName && brandSet.has(String(bName).trim().toLowerCase())) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply supplier filter if set
|
||||||
|
const selectedSuppliers = parseFilter(supplier);
|
||||||
|
if (selectedSuppliers.length > 0) {
|
||||||
|
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||||
|
const allowedSkus = new Set();
|
||||||
|
for (const sId of selectedSuppliers) {
|
||||||
|
const list = articlesBySupplier[String(sId)] ?? [];
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.article?.sku) allowedSkus.add(String(item.article.sku).toLowerCase());
|
||||||
|
if (item.sku) allowedSkus.add(String(item.sku).toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredArticles = filteredArticles.filter((row) => {
|
||||||
|
const rowSku = String(row.sku ?? '').toLowerCase();
|
||||||
|
return allowedSkus.has(rowSku);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply hierarchy filter:
|
||||||
|
// - "father": "father or normal article" -> brand editable, exclude child variants
|
||||||
|
// - "child": "child or normal article" -> price editable, exclude parents
|
||||||
|
if (hierarchy === 'father') {
|
||||||
|
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
|
||||||
|
} else if (hierarchy === 'child') {
|
||||||
|
filteredArticles = filteredArticles.filter(
|
||||||
|
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
|
||||||
|
const emptyFilters = parseFilter(empty);
|
||||||
|
if (emptyFilters.length > 0) {
|
||||||
|
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||||
|
filteredArticles = filteredArticles.filter((row) => {
|
||||||
|
for (const f of emptyFilters) {
|
||||||
|
if (f === 'ean') {
|
||||||
|
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||||
|
(row.ean && String(row.ean).trim());
|
||||||
|
if (hasEan) return false;
|
||||||
|
} else if (f === 'photo') {
|
||||||
|
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||||
|
const hasPhoto = Boolean(
|
||||||
|
row.fullHash ||
|
||||||
|
row.thumbHash ||
|
||||||
|
row.smallThumbBinary ||
|
||||||
|
firstPhoto?.fullHash ||
|
||||||
|
firstPhoto?.thumbHash ||
|
||||||
|
firstPhoto?.smallThumbBinary
|
||||||
|
);
|
||||||
|
if (hasPhoto) return false;
|
||||||
|
} else if (f === 'price') {
|
||||||
|
const p = row.price ?? row.net;
|
||||||
|
const hasPrice = p != null && Number(p) > 0;
|
||||||
|
if (hasPrice) return false;
|
||||||
|
} else if (f === 'brand') {
|
||||||
|
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||||
|
if (hasBrand) return false;
|
||||||
|
} else if (f === 'supplier') {
|
||||||
|
const hasSupplier = Boolean(
|
||||||
|
(row.suppliers && row.suppliers.length > 0) ||
|
||||||
|
row.supplierId != null ||
|
||||||
|
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||||
|
);
|
||||||
|
if (hasSupplier) return false;
|
||||||
|
} else if (f === 'seo') {
|
||||||
|
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||||
|
if (hasSeo) return false;
|
||||||
|
} else if (f === 'desc') {
|
||||||
|
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||||
|
if (hasDesc) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
||||||
|
|
||||||
const totalCount = sortedArticles.length;
|
const totalCount = sortedArticles.length;
|
||||||
@@ -503,6 +894,8 @@ const actions = {
|
|||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
categoryFacets,
|
categoryFacets,
|
||||||
|
brandFacets,
|
||||||
|
supplierFacets,
|
||||||
totalCount,
|
totalCount,
|
||||||
recordset,
|
recordset,
|
||||||
offset,
|
offset,
|
||||||
@@ -516,6 +909,10 @@ const actions = {
|
|||||||
result,
|
result,
|
||||||
target,
|
target,
|
||||||
filter,
|
filter,
|
||||||
|
brand,
|
||||||
|
supplier,
|
||||||
|
empty,
|
||||||
|
hierarchy,
|
||||||
offset,
|
offset,
|
||||||
pageSize: safePageSize,
|
pageSize: safePageSize,
|
||||||
loading: articleIndexAutomat.state.status === 'loading',
|
loading: articleIndexAutomat.state.status === 'loading',
|
||||||
@@ -523,7 +920,10 @@ const actions = {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region search
|
||||||
|
// MARK: calls articleIndexAutomat.actions.ensureLoaded
|
||||||
/**
|
/**
|
||||||
* Runs a client-side search over the article index with pagination.
|
* Runs a client-side search over the article index with pagination.
|
||||||
*
|
*
|
||||||
@@ -548,7 +948,10 @@ const actions = {
|
|||||||
|
|
||||||
return actions.runSearch(params);
|
return actions.runSearch(params);
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region setPageSize
|
||||||
|
// MARK: updates page size and re-derives result set
|
||||||
/**
|
/**
|
||||||
* Updates the page size and re-derives the result set.
|
* Updates the page size and re-derives the result set.
|
||||||
*
|
*
|
||||||
@@ -558,7 +961,10 @@ const actions = {
|
|||||||
setPageSize(pageSize) {
|
setPageSize(pageSize) {
|
||||||
return actions.runSearch({ pageSize });
|
return actions.runSearch({ pageSize });
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region setPage
|
||||||
|
// MARK: sets 1-based page number and re-derives result set
|
||||||
/**
|
/**
|
||||||
* Sets page number (1-based) and re-derives the result set.
|
* Sets page number (1-based) and re-derives the result set.
|
||||||
*
|
*
|
||||||
@@ -570,7 +976,10 @@ const actions = {
|
|||||||
const offset = Math.max(0, (page - 1) * safeSize);
|
const offset = Math.max(0, (page - 1) * safeSize);
|
||||||
return actions.runSearch({ offset });
|
return actions.runSearch({ offset });
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region setOffset
|
||||||
|
// MARK: sets 0-based offset and re-derives result set
|
||||||
/**
|
/**
|
||||||
* Sets 0-based offset and re-derives the result set.
|
* Sets 0-based offset and re-derives the result set.
|
||||||
*
|
*
|
||||||
@@ -580,7 +989,10 @@ const actions = {
|
|||||||
setOffset(offset) {
|
setOffset(offset) {
|
||||||
return actions.runSearch({ offset });
|
return actions.runSearch({ offset });
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region reset
|
||||||
|
// MARK: resets search state and clears FlexSearch index
|
||||||
/** Reset the search state and clear FlexSearch index. */
|
/** Reset the search state and clear FlexSearch index. */
|
||||||
reset() {
|
reset() {
|
||||||
flexDocument = null;
|
flexDocument = null;
|
||||||
@@ -588,7 +1000,12 @@ const actions = {
|
|||||||
articleByInternalId = new Map();
|
articleByInternalId = new Map();
|
||||||
articleSearchAutomat.setState(initialState);
|
articleSearchAutomat.setState(initialState);
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Automat Instance & Subscriptions
|
||||||
|
// MARK: initializes Automat instance and subscribes to articleIndexAutomat & categoryAutomat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Article search and pagination state for the `/artikel` overview page.
|
* Article search and pagination state for the `/artikel` overview page.
|
||||||
@@ -612,6 +1029,15 @@ categoryAutomat.subscribe((catState) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update facets when brands, suppliers, or supplier articles are loaded or updated.
|
||||||
|
companyAutomat.subscribe(() => {
|
||||||
|
actions.runSearch();
|
||||||
|
}, ['brands', 'suppliers', 'articlesBySupplier']);
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Selectors & Exports
|
||||||
|
// MARK: selectSearchResult selector and default export
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the latest search result.
|
* Selects the latest search result.
|
||||||
*
|
*
|
||||||
@@ -623,3 +1049,4 @@ export function selectSearchResult(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default articleSearchAutomat;
|
export default articleSearchAutomat;
|
||||||
|
// #endregion
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import { Automat } from 'react-automat';
|
|||||||
import { article as articleApi } from '../api';
|
import { article as articleApi } from '../api';
|
||||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat';
|
import articleIndexAutomat, {
|
||||||
|
setArticleIndexName,
|
||||||
|
setArticleIndexStock,
|
||||||
|
updateArticleIndexRecord,
|
||||||
|
} from './articleIndexAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ArtikelState
|
* @typedef {Object} ArtikelState
|
||||||
@@ -113,6 +118,7 @@ const actions = {
|
|||||||
artikelAutomat.setState({
|
artikelAutomat.setState({
|
||||||
bySku: { ...artikelAutomat.state.bySku, [key]: article },
|
bySku: { ...artikelAutomat.state.bySku, [key]: article },
|
||||||
});
|
});
|
||||||
|
updateArticleIndexRecord(article);
|
||||||
return article;
|
return article;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -163,9 +169,8 @@ const actions = {
|
|||||||
actions.setArticleName(key, body.name);
|
actions.setArticleName(key, body.name);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await articleApi.update(key, body);
|
await articleApi.update(key, body);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(key) };
|
||||||
return res;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (nameChange) {
|
if (nameChange) {
|
||||||
// The optimistic patch was wrong: drop the index entry and refetch.
|
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||||
@@ -186,9 +191,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setEans(sku, eans) {
|
async setEans(sku, eans) {
|
||||||
const res = await articleApi.setEansBySku(String(sku), eans);
|
await articleApi.setEansBySku(String(sku), eans);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,9 +203,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setBrand(sku, body) {
|
async setBrand(sku, body) {
|
||||||
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,9 +215,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setCategories(sku, categoryIds) {
|
async setCategories(sku, categoryIds) {
|
||||||
const res = await articleApi.setCategories(String(sku), categoryIds);
|
await articleApi.setCategories(String(sku), categoryIds);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,9 +227,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setPrices(sku, prices) {
|
async setPrices(sku, prices) {
|
||||||
const res = await articleApi.setPrices(String(sku), prices);
|
await articleApi.setPrices(String(sku), prices);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,9 +239,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setWeight(sku, body) {
|
async setWeight(sku, body) {
|
||||||
const res = await articleApi.setWeight(String(sku), body);
|
await articleApi.setWeight(String(sku), body);
|
||||||
actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,9 +251,8 @@ const actions = {
|
|||||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
*/
|
*/
|
||||||
async setTrackStock(sku, body) {
|
async setTrackStock(sku, body) {
|
||||||
const res = await articleApi.setTrackStock(String(sku), body);
|
await articleApi.setTrackStock(String(sku), body);
|
||||||
if (res?.article) actions.setArticle(res.article);
|
return { article: await actions.reloadArticle(sku) };
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,4 +325,14 @@ authAutomat.subscribe((auth) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upstream Trigger: sseAutomat
|
||||||
|
if (sseAutomat?.actions?.onArticle) {
|
||||||
|
sseAutomat.actions.onArticle((sku) => {
|
||||||
|
const key = String(sku);
|
||||||
|
if (key in artikelAutomat.state.bySku) {
|
||||||
|
void actions.reloadArticle(key).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default artikelAutomat;
|
export default artikelAutomat;
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file attributeAutomat.js
|
|
||||||
* @description Manages catalog product attributes and their discrete values
|
|
||||||
* (e.g. Color, Size) for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
* - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event.
|
|
||||||
* - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`,
|
|
||||||
* `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} AttributeValue
|
|
||||||
* @property {number} id
|
|
||||||
* @property {number} attributeId
|
|
||||||
* @property {string} value
|
|
||||||
* @property {number} sort
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} AttributeName
|
|
||||||
* @property {number} id
|
|
||||||
* @property {string} name
|
|
||||||
* @property {number} sort
|
|
||||||
* @property {boolean} exclusive
|
|
||||||
* @property {AttributeValue[]} values
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} AttributeState
|
|
||||||
* @property {AttributeName[]} attributes
|
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
|
||||||
* @property {string|null} error
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {AttributeState} */
|
|
||||||
const initialState = {
|
|
||||||
attributes: [],
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function sortValues(values) {
|
|
||||||
if (!Array.isArray(values)) return [];
|
|
||||||
return [...values].sort((a, b) => {
|
|
||||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
|
||||||
if (sDiff !== 0) return sDiff;
|
|
||||||
return (a.id ?? 0) - (b.id ?? 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAttributes(attributes) {
|
|
||||||
if (!Array.isArray(attributes)) return [];
|
|
||||||
return [...attributes]
|
|
||||||
.map((attr) => ({
|
|
||||||
...attr,
|
|
||||||
exclusive: Boolean(attr.exclusive),
|
|
||||||
values: sortValues(attr.values),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
|
||||||
if (sDiff !== 0) return sDiff;
|
|
||||||
return (a.id ?? 0) - (b.id ?? 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let attributeAutomat;
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/** Load all attribute names and their values for the selected company. */
|
|
||||||
async load() {
|
|
||||||
attributeAutomat.setState({ status: 'loading', error: null });
|
|
||||||
try {
|
|
||||||
const res = await attributenameApi.list();
|
|
||||||
const attributes = normalizeAttributes(res.attributenames);
|
|
||||||
attributeAutomat.setState({ attributes, status: 'succeeded' });
|
|
||||||
return attributes;
|
|
||||||
} catch (err) {
|
|
||||||
attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an attribute name.
|
|
||||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
|
||||||
*/
|
|
||||||
async createAttribute(body) {
|
|
||||||
const res = await attributenameApi.create(body);
|
|
||||||
await actions.load();
|
|
||||||
return res.attributename;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an attribute name.
|
|
||||||
* @param {number} id
|
|
||||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
|
||||||
*/
|
|
||||||
async updateAttribute(id, body) {
|
|
||||||
const res = await attributenameApi.update(id, body);
|
|
||||||
await actions.load();
|
|
||||||
return res.attributename;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an attribute name.
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
async deleteAttribute(id) {
|
|
||||||
await attributenameApi.delete(id);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder attribute names.
|
|
||||||
* @param {number[]} attributenameIds
|
|
||||||
*/
|
|
||||||
async reorderAttributes(attributenameIds) {
|
|
||||||
await attributenameApi.order(attributenameIds);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a value for an attribute.
|
|
||||||
* @param {number} attributeId
|
|
||||||
* @param {{ value: string, sort?: number }} body
|
|
||||||
*/
|
|
||||||
async createValue(attributeId, body) {
|
|
||||||
const res = await attributevalueApi.create(attributeId, body);
|
|
||||||
await actions.load();
|
|
||||||
return res.attributevalue;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an attribute value.
|
|
||||||
* @param {number} id
|
|
||||||
* @param {{ value?: string, sort?: number }} body
|
|
||||||
*/
|
|
||||||
async updateValue(id, body) {
|
|
||||||
const res = await attributevalueApi.update(id, body);
|
|
||||||
await actions.load();
|
|
||||||
return res.attributevalue;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an attribute value.
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
async deleteValue(id) {
|
|
||||||
await attributevalueApi.delete(id);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder values of an attribute.
|
|
||||||
* @param {number} attributeId
|
|
||||||
* @param {number[]} attributevalueIds
|
|
||||||
*/
|
|
||||||
async reorderValues(attributeId, attributevalueIds) {
|
|
||||||
await attributevalueApi.order(attributeId, attributevalueIds);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Reset to idle state (called on logout / company switch). */
|
|
||||||
reset() {
|
|
||||||
attributeAutomat.setState(initialState);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global attribute state container.
|
|
||||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
|
||||||
*
|
|
||||||
* @type {Automat<AttributeState>}
|
|
||||||
*/
|
|
||||||
attributeAutomat = new Automat(initialState, actions, { name: 'attribute' });
|
|
||||||
|
|
||||||
// Selectors
|
|
||||||
export function selectAttributes() {
|
|
||||||
return attributeAutomat.state.attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectAttributeStatus() {
|
|
||||||
return attributeAutomat.state.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to auth changes
|
|
||||||
authAutomat.subscribe((auth) => {
|
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
|
||||||
if (auth.status === 'authed' && companyId) {
|
|
||||||
void attributeAutomat.actions.load();
|
|
||||||
} else if (auth.status === 'anon') {
|
|
||||||
attributeAutomat.actions.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default attributeAutomat;
|
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @file authAutomat.js
|
* @file authAutomat.js
|
||||||
* @description Central authentication and multi-tenant session manager.
|
* @description Central authentication and multi-tenant session manager.
|
||||||
* Tracks current user, Google OAuth client ID, and active company selection.
|
* Tracks current user, Google OAuth client ID, active company selection, and
|
||||||
|
* the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`).
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
|
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
|
||||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`.
|
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`.
|
||||||
* - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`.
|
* - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
|
||||||
|
* `createCompany(payload)`, or `logout()`.
|
||||||
*
|
*
|
||||||
* Downstream Triggers:
|
* Downstream Triggers:
|
||||||
* Subscribed to by almost all company-scoped automats, orchestrating their loading
|
* - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`.
|
||||||
* when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`):
|
* - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet).
|
||||||
* - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`
|
* - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat`
|
||||||
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat`
|
* are mounted and company APIs are triggered.
|
||||||
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
|
|
||||||
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
|
|
||||||
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
|
|
||||||
* the category views once the session is established
|
|
||||||
* - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`,
|
|
||||||
* `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
|
|
||||||
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
|
|
||||||
* - `artikelAutomat`: Clears cached articles on logout
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { auth, company } from '../api';
|
import { auth, company, number as numberApi } from '../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} AuthUser
|
* @typedef {Object} AuthUser
|
||||||
@@ -36,7 +30,13 @@ import { auth, company } from '../api';
|
|||||||
* @typedef {Object} AuthState
|
* @typedef {Object} AuthState
|
||||||
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
||||||
* @property {string|null} googleClientId Google Identity Services client ID.
|
* @property {string|null} googleClientId Google Identity Services client ID.
|
||||||
|
* @property {number|null} schemaVersion Database schema version returned by the backend.
|
||||||
|
* @property {boolean} vhost Whether vhost mode is enabled.
|
||||||
|
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
|
||||||
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
||||||
|
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
||||||
|
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
||||||
|
* @property {string|null} retryNotification Message indicating a pending retry (e.g. 502 Bad Gateway).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Emitted after an authenticated session has been established. */
|
/** Emitted after an authenticated session has been established. */
|
||||||
@@ -50,11 +50,30 @@ export const AUTH_LOGGED_OUT = 'auth/loggedOut';
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
user: null,
|
user: null,
|
||||||
googleClientId: null,
|
googleClientId: null,
|
||||||
|
schemaVersion: null,
|
||||||
|
vhost: false,
|
||||||
|
vhostSettings: null,
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
|
hasAuth: false,
|
||||||
|
hasCompany: false,
|
||||||
|
retryNotification: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let emit = () => { };
|
let emit = () => { };
|
||||||
|
|
||||||
|
// #region helpers
|
||||||
|
/**
|
||||||
|
* Detects whether an error represents an HTTP 502 Bad Gateway response.
|
||||||
|
*
|
||||||
|
* @param {any} err
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function is502BadGateway(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
if (Number(err.status) === 502) return true;
|
||||||
|
const msg = String(err.message || '');
|
||||||
|
return /502|bad\s*gateway/i.test(msg);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Registers the application-level dispatcher used for authentication events.
|
* Registers the application-level dispatcher used for authentication events.
|
||||||
*
|
*
|
||||||
@@ -65,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
|
|||||||
emit = dispatch;
|
emit = dispatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePicture(picture) {
|
export function normalizePicture(picture) {
|
||||||
if (!picture || typeof picture !== 'string') return null;
|
if (!picture || typeof picture !== 'string') return null;
|
||||||
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
|
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
|
||||||
return picture;
|
return picture;
|
||||||
@@ -93,43 +112,132 @@ function mergeUser(current, incoming) {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`).
|
||||||
|
*
|
||||||
|
* @param {AuthUser|null} user
|
||||||
|
* @param {'loading'|'anon'|'authed'} status
|
||||||
|
* @param {object} [extra]
|
||||||
|
* @returns {Partial<AuthState>}
|
||||||
|
*/
|
||||||
|
function buildAuthState(user, status, extra = {}) {
|
||||||
|
const hasAuth = status === 'authed' && Boolean(user);
|
||||||
|
const hasCompany = hasAuth && user?.selectedCompanyId != null;
|
||||||
|
return {
|
||||||
|
user: hasAuth ? user : null,
|
||||||
|
status,
|
||||||
|
hasAuth,
|
||||||
|
hasCompany,
|
||||||
|
vhost: authAutomat?.state?.vhost ?? false,
|
||||||
|
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
|
||||||
|
retryNotification: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSse(state) {
|
||||||
|
const sseAutomat = Automat.get('sse');
|
||||||
|
if (sseAutomat?.actions?.syncWithAuth) {
|
||||||
|
sseAutomat.actions.syncWithAuth(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySession(nextSession) {
|
||||||
|
const googleClientId =
|
||||||
|
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||||
|
const schemaVersion =
|
||||||
|
nextSession.schemaVersion !== undefined
|
||||||
|
? nextSession.schemaVersion
|
||||||
|
: (authAutomat.state.schemaVersion ?? null);
|
||||||
|
const vhost =
|
||||||
|
nextSession.vhost !== undefined
|
||||||
|
? Boolean(nextSession.vhost)
|
||||||
|
: (authAutomat.state.vhost ?? false);
|
||||||
|
const vhostSettings =
|
||||||
|
nextSession.vhostSettings !== undefined
|
||||||
|
? nextSession.vhostSettings
|
||||||
|
: (authAutomat.state.vhostSettings ?? null);
|
||||||
|
|
||||||
|
if (!nextSession.user) {
|
||||||
|
if (authAutomat.state.status !== 'authed') {
|
||||||
|
const nextState = authAutomat.setState(
|
||||||
|
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||||
|
);
|
||||||
|
syncSse(nextState);
|
||||||
|
} else {
|
||||||
|
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
|
||||||
|
}
|
||||||
|
return nextSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||||
|
const nextState = authAutomat.setState(
|
||||||
|
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||||
|
);
|
||||||
|
syncSse(nextState);
|
||||||
|
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||||
|
return nextSession;
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
let authAutomat;
|
let authAutomat;
|
||||||
|
let inFlightBootstrap = null;
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
// #region bootstrapSession
|
||||||
|
// MARK: calls auth.me from ../api
|
||||||
/**
|
/**
|
||||||
* Restores an existing session or requests it from the authentication API.
|
* Restores an existing session or requests it from the authentication API.
|
||||||
|
* Retries automatically every 5 seconds if a 502 Bad Gateway is received.
|
||||||
*
|
*
|
||||||
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
|
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
|
||||||
* Optional preloaded session.
|
* Optional preloaded session.
|
||||||
* @returns {Promise<Object>} Resolved session response.
|
* @returns {Promise<Object>} Resolved session response.
|
||||||
*/
|
*/
|
||||||
async bootstrapSession(session) {
|
async bootstrapSession(session) {
|
||||||
try {
|
if (session) {
|
||||||
const nextSession = session ?? await auth.me();
|
return applySession(session);
|
||||||
const googleClientId =
|
|
||||||
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
|
||||||
|
|
||||||
if (!nextSession.user) {
|
|
||||||
if (authAutomat.state.status !== 'authed') {
|
|
||||||
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
|
|
||||||
} else {
|
|
||||||
authAutomat.setState({ googleClientId });
|
|
||||||
}
|
|
||||||
return nextSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
|
||||||
authAutomat.setState({ user, googleClientId, status: 'authed' });
|
|
||||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
|
||||||
return nextSession;
|
|
||||||
} catch (error) {
|
|
||||||
if (authAutomat.state.status === 'loading') {
|
|
||||||
authAutomat.setState({ user: null, status: 'anon' });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
|
if (inFlightBootstrap) {
|
||||||
|
return inFlightBootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightBootstrap = (async () => {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const nextSession = await auth.me();
|
||||||
|
return applySession(nextSession);
|
||||||
|
} catch (error) {
|
||||||
|
if (is502BadGateway(error)) {
|
||||||
|
attempt++;
|
||||||
|
authAutomat.setState({
|
||||||
|
retryNotification: `Server nicht erreichbar (502 Bad Gateway) – erneuter Versuch in 5s…${attempt > 1 ? ` (Versuch ${attempt})` : ''}`,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authAutomat.state.status === 'loading') {
|
||||||
|
const nextState = authAutomat.setState(
|
||||||
|
buildAuthState(null, 'anon', { retryNotification: null })
|
||||||
|
);
|
||||||
|
syncSse(nextState);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
inFlightBootstrap = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return inFlightBootstrap;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region loginWithGoogle
|
||||||
|
// MARK: calls auth.loginWithGoogle from ../api
|
||||||
/**
|
/**
|
||||||
* Exchanges a Google credential for an authenticated application session.
|
* Exchanges a Google credential for an authenticated application session.
|
||||||
*
|
*
|
||||||
@@ -143,19 +251,25 @@ const actions = {
|
|||||||
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
||||||
}
|
}
|
||||||
const user = mergeUser(authAutomat.state.user, session.user);
|
const user = mergeUser(authAutomat.state.user, session.user);
|
||||||
authAutomat.setState({
|
const nextState = authAutomat.setState(
|
||||||
user,
|
buildAuthState(user, 'authed', {
|
||||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||||
status: 'authed',
|
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
syncSse(nextState);
|
||||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||||
return session;
|
return session;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authAutomat.setState({ user: null, status: 'anon' });
|
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||||
|
syncSse(nextState);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region selectCompany
|
||||||
|
// MARK: calls company.select from ../api
|
||||||
/**
|
/**
|
||||||
* Selects the company used by the current session.
|
* Selects the company used by the current session.
|
||||||
*
|
*
|
||||||
@@ -168,11 +282,15 @@ const actions = {
|
|||||||
authAutomat.state.user,
|
authAutomat.state.user,
|
||||||
data.user ?? { selectedCompanyId: companyId },
|
data.user ?? { selectedCompanyId: companyId },
|
||||||
);
|
);
|
||||||
authAutomat.setState({ user, status: 'authed' });
|
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||||
|
syncSse(nextState);
|
||||||
emit({ type: AUTH_COMPANY_CHANGED });
|
emit({ type: AUTH_COMPANY_CHANGED });
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region createCompany
|
||||||
|
// MARK: calls company.create and company.select from ../api
|
||||||
/**
|
/**
|
||||||
* Creates a company and selects it for the current user.
|
* Creates a company and selects it for the current user.
|
||||||
*
|
*
|
||||||
@@ -180,7 +298,8 @@ const actions = {
|
|||||||
* @returns {Promise<AuthUser>} Updated user.
|
* @returns {Promise<AuthUser>} Updated user.
|
||||||
*/
|
*/
|
||||||
async createCompany(payload) {
|
async createCompany(payload) {
|
||||||
const data = await company.create(payload);
|
const { numbers, ...companyPayload } = payload || {};
|
||||||
|
const data = await company.create(companyPayload);
|
||||||
let incoming = data.user;
|
let incoming = data.user;
|
||||||
if (!incoming && data.company?.id) {
|
if (!incoming && data.company?.id) {
|
||||||
const selected = await company.select(data.company.id);
|
const selected = await company.select(data.company.id);
|
||||||
@@ -190,12 +309,34 @@ const actions = {
|
|||||||
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (numbers) {
|
||||||
|
try {
|
||||||
|
const listRes = await numberApi.list();
|
||||||
|
const serverNumbers = listRes?.numbers ?? [];
|
||||||
|
for (const serverRow of serverNumbers) {
|
||||||
|
const userConfig = numbers[serverRow.type];
|
||||||
|
if (userConfig) {
|
||||||
|
const prefix = String(userConfig.prefix ?? '');
|
||||||
|
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||||
|
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||||
|
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (numErr) {
|
||||||
|
console.warn('Initial number sequences could not be saved:', numErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = mergeUser(authAutomat.state.user, incoming);
|
const user = mergeUser(authAutomat.state.user, incoming);
|
||||||
authAutomat.setState({ user, status: 'authed' });
|
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||||
|
syncSse(nextState);
|
||||||
emit({ type: AUTH_COMPANY_CHANGED });
|
emit({ type: AUTH_COMPANY_CHANGED });
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region logout
|
||||||
|
// MARK: calls auth.logout from ../api
|
||||||
/**
|
/**
|
||||||
* Ends the server session, clears the current user, and returns to `/`.
|
* Ends the server session, clears the current user, and returns to `/`.
|
||||||
*
|
*
|
||||||
@@ -204,9 +345,11 @@ const actions = {
|
|||||||
async logout() {
|
async logout() {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
authAutomat.setState({ user: null, status: 'anon' });
|
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||||
|
syncSse(nextState);
|
||||||
emit({ type: AUTH_LOGGED_OUT });
|
emit({ type: AUTH_LOGGED_OUT });
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,16 +359,27 @@ const actions = {
|
|||||||
*/
|
*/
|
||||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||||
|
|
||||||
|
// #region selectors
|
||||||
|
export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
|
||||||
|
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
|
||||||
|
export const selectSelectedCompanyId = () =>
|
||||||
|
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
||||||
|
export const selectRetryNotification = () => authAutomat.state.retryNotification;
|
||||||
|
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
|
||||||
|
export const selectVhost = () => Boolean(authAutomat.state.vhost);
|
||||||
|
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-reads `GET /api/auth/me` from the server.
|
* Re-reads `GET /api/auth/me` from the server.
|
||||||
*
|
*
|
||||||
* Called after login, logout, and company switches so the automat reflects
|
* Called after login, logout, and company switches so the automat reflects
|
||||||
* the current server session rather than an optimistic guess.
|
* the current server session rather than an optimistic guess.
|
||||||
*
|
*
|
||||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
|
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null, schemaVersion?: number|null}>} Session.
|
||||||
*/
|
*/
|
||||||
export function bootstrapAuthMe() {
|
export function bootstrapAuthMe() {
|
||||||
return auth.me();
|
return authAutomat.actions.bootstrapSession();
|
||||||
}
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
export default authAutomat;
|
export default authAutomat;
|
||||||
|
|||||||
229
src/automats/backupAutomat.js
Normal file
229
src/automats/backupAutomat.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* @file backupAutomat.js
|
||||||
|
* @description Manages backup status, archive listings, and VFS file recovery from backup archives (`GET /backup`, `GET /backup/:id/vfs`, `POST /backup/:id/vfs/recover`).
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||||
|
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||||
|
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
|
||||||
|
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { backup as backupApi } from '../api';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupItem
|
||||||
|
* @property {string} name Archive file or directory name
|
||||||
|
* @property {string} path Full storage path
|
||||||
|
* @property {number} totalSizeBytes Total size in bytes
|
||||||
|
* @property {string|null} timestamp Creation timestamp
|
||||||
|
* @property {object|null} [manifest] Backup manifest details
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupStatus
|
||||||
|
* @property {boolean} enabled Whether automated backups are enabled
|
||||||
|
* @property {boolean} isRunning Whether a backup is currently executing
|
||||||
|
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
|
||||||
|
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
|
||||||
|
* @property {string} backupDir Root directory where backups are stored
|
||||||
|
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
|
||||||
|
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
|
||||||
|
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
|
||||||
|
* @property {BackupItem|null} [latestDaily] Most recent daily backup
|
||||||
|
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupState
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
||||||
|
* @property {string|null} error Last error message
|
||||||
|
* @property {BackupStatus|null} backupStatus Service status and retention settings
|
||||||
|
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
|
||||||
|
* @property {Record<string, Array<{ name: string, size: number, mime: string, mtime: string }>>} vfsFilesByBackup
|
||||||
|
* @property {Record<string, boolean>} vfsLoadingByBackup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {BackupState} */
|
||||||
|
const initialState = {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
backupStatus: null,
|
||||||
|
backups: {
|
||||||
|
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||||
|
hourly: [],
|
||||||
|
daily: [],
|
||||||
|
monthly: [],
|
||||||
|
},
|
||||||
|
vfsFilesByBackup: {},
|
||||||
|
vfsLoadingByBackup: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let backupAutomat;
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
// #region fetch
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
async function fetchBackupData() {
|
||||||
|
const data = await backupApi.getStatus();
|
||||||
|
const status = data?.status ?? null;
|
||||||
|
const rawBackups = data?.backups ?? {};
|
||||||
|
|
||||||
|
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
|
||||||
|
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
|
||||||
|
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
|
||||||
|
|
||||||
|
const counts = rawBackups.counts ?? {
|
||||||
|
hourly: hourly.length,
|
||||||
|
daily: daily.length,
|
||||||
|
monthly: monthly.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupStatus: status,
|
||||||
|
backups: {
|
||||||
|
counts,
|
||||||
|
hourly,
|
||||||
|
daily,
|
||||||
|
monthly,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
// #region load
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Loads backup status and archive listing.
|
||||||
|
* Deduplicates concurrent callers.
|
||||||
|
*
|
||||||
|
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
|
||||||
|
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
|
||||||
|
*/
|
||||||
|
load(silent = false) {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
if (!silent) {
|
||||||
|
backupAutomat.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
try {
|
||||||
|
const { backupStatus, backups } = await fetchBackupData();
|
||||||
|
backupAutomat.setState({
|
||||||
|
backupStatus,
|
||||||
|
backups,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return { backupStatus, backups };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err?.message ?? String(err);
|
||||||
|
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region reload
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Silently re-fetches backup status and archives in the background.
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
return actions.load(true);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region listBackupVfs
|
||||||
|
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Retrieves the list of VFS files stored inside a specific backup.
|
||||||
|
*
|
||||||
|
* @param {string} backupId Identifier of the backup
|
||||||
|
* @param {number} [companyId] Optional company ID override
|
||||||
|
* @returns {Promise<Array<{ name: string, size: number, mime: string, mtime: string }>>}
|
||||||
|
*/
|
||||||
|
async listBackupVfs(backupId, companyId) {
|
||||||
|
if (!backupId) return [];
|
||||||
|
backupAutomat.setState({
|
||||||
|
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await backupApi.listVfs(backupId, companyId);
|
||||||
|
const files = Array.isArray(res?.files) ? res.files : [];
|
||||||
|
backupAutomat.setState({
|
||||||
|
vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files },
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
} finally {
|
||||||
|
backupAutomat.setState({
|
||||||
|
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region recoverBackupVfs
|
||||||
|
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Restores a single VFS file from the specified backup into the active VFS.
|
||||||
|
*
|
||||||
|
* @param {string} backupId Identifier of the backup
|
||||||
|
* @param {string} path Relative file path within the VFS
|
||||||
|
* @param {number} [companyId] Optional company ID override
|
||||||
|
* @returns {Promise<object>} Recovery result
|
||||||
|
*/
|
||||||
|
async recoverBackupVfs(backupId, path, companyId) {
|
||||||
|
return backupApi.recoverVfs(backupId, path, companyId);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the automat state to initial empty values.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
backupAutomat.setState({ ...initialState });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
|
||||||
|
|
||||||
|
// #region sseListener
|
||||||
|
if (sseAutomat?.actions?.on) {
|
||||||
|
sseAutomat.actions.on('backup', () => {
|
||||||
|
void backupAutomat.actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region authSync
|
||||||
|
function authKey(authState) {
|
||||||
|
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||||
|
const companyId = authState.user.selectedCompanyId;
|
||||||
|
return companyId == null ? null : String(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAuthKey;
|
||||||
|
function syncWithAuth(authState) {
|
||||||
|
const key = authKey(authState);
|
||||||
|
if (key === lastAuthKey) return;
|
||||||
|
lastAuthKey = key;
|
||||||
|
if (key == null) {
|
||||||
|
backupAutomat.actions.clear();
|
||||||
|
} else {
|
||||||
|
void backupAutomat.actions.load().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authAutomat.subscribe(syncWithAuth);
|
||||||
|
syncWithAuth(authAutomat.state);
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
export default backupAutomat;
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file brandAutomat.js
|
|
||||||
* @description Manages catalog brands and manufacturers for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
|
|
||||||
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
|
|
||||||
* `actions.load(true)`, which silently re-fetches the list. Subscribed views
|
|
||||||
* (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then
|
|
||||||
* re-render from the updated state. Events sent by this tab (matching `sender`)
|
|
||||||
* are ignored.
|
|
||||||
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import { brand as brandApi } from '../api';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
import sseAutomat, { getSseClientId } from './sseAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} BrandState
|
|
||||||
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
|
||||||
* @property {Array<object>} filteredBrands Filtered brands according to active filter query.
|
|
||||||
* @property {string} filter Active search/filter string.
|
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
|
||||||
* @property {string|null} error
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {BrandState} */
|
|
||||||
const initialState = {
|
|
||||||
brands: [],
|
|
||||||
filteredBrands: [],
|
|
||||||
filter: '',
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortBrands = (brands) =>
|
|
||||||
[...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
|
||||||
|
|
||||||
const applyFilter = (brands, filter) => {
|
|
||||||
const query = String(filter ?? '').trim().toLowerCase();
|
|
||||||
return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands;
|
|
||||||
};
|
|
||||||
|
|
||||||
let brandAutomat;
|
|
||||||
let inflightLoad = null;
|
|
||||||
const FETCH_PAGE_SIZE = 100;
|
|
||||||
// #region fetch
|
|
||||||
async function fetchAllBrands() {
|
|
||||||
let offset = 0, total = Infinity;
|
|
||||||
const collected = [];
|
|
||||||
while (offset === 0 || collected.length < total) {
|
|
||||||
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
|
||||||
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
|
||||||
const page = Array.isArray(res.brands) ? res.brands : [];
|
|
||||||
collected.push(...page);
|
|
||||||
offset += page.length;
|
|
||||||
if (page.length === 0) break;
|
|
||||||
}
|
|
||||||
return { brands: sortBrands(collected) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateStateWithBrands = (brands, extra = {}) => {
|
|
||||||
const filteredBrands = applyFilter(brands, brandAutomat.state.filter);
|
|
||||||
brandAutomat.setState({ brands, filteredBrands, ...extra });
|
|
||||||
return brands;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/** Sets the active brand search filter and updates filteredBrands. */
|
|
||||||
filter(query) {
|
|
||||||
const filter = String(query ?? '');
|
|
||||||
brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all brands for the selected company (pages through the API).
|
|
||||||
* @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'.
|
|
||||||
*/
|
|
||||||
async load(silent = false) {
|
|
||||||
if (inflightLoad) return inflightLoad;
|
|
||||||
if (!silent) {
|
|
||||||
brandAutomat.setState({ status: 'loading', error: null });
|
|
||||||
}
|
|
||||||
return (inflightLoad = fetchAllBrands()
|
|
||||||
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
|
|
||||||
.catch((err) => {
|
|
||||||
const error = err?.message ?? String(err);
|
|
||||||
brandAutomat.setState(silent ? { error } : { status: 'failed', error });
|
|
||||||
throw err;
|
|
||||||
})
|
|
||||||
.finally(() => { inflightLoad = null; }));
|
|
||||||
},
|
|
||||||
// #region add
|
|
||||||
async create(body) {
|
|
||||||
const res = await brandApi.create(body);
|
|
||||||
const row = res.brand;
|
|
||||||
updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row]));
|
|
||||||
return row;
|
|
||||||
},
|
|
||||||
// #region rename
|
|
||||||
async rename(id, body) {
|
|
||||||
const res = await brandApi.rename(id, body);
|
|
||||||
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
|
||||||
updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b))));
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
// #region del
|
|
||||||
async remove(id) {
|
|
||||||
await brandApi.delete(id);
|
|
||||||
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
|
|
||||||
},
|
|
||||||
// #region addPic
|
|
||||||
async addImage(id, file) {
|
|
||||||
const res = await brandApi.addImage(id, file);
|
|
||||||
await brandAutomat.actions.load(true);
|
|
||||||
return res.brand;
|
|
||||||
},
|
|
||||||
// #region delPic
|
|
||||||
async deleteImage(id) {
|
|
||||||
const res = await brandApi.deleteImage(id);
|
|
||||||
const updated = res.brand ?? { pics: [] };
|
|
||||||
updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b)));
|
|
||||||
return res.brand;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global brand state container.
|
|
||||||
* @type {Automat<BrandState>}
|
|
||||||
*/
|
|
||||||
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
|
|
||||||
|
|
||||||
authAutomat.subscribe((auth) => {
|
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
|
||||||
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
|
|
||||||
else if (auth.status === 'anon') brandAutomat.setState(initialState);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #region onSse
|
|
||||||
sseAutomat.actions.on('brand', (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event?.data ?? '{}');
|
|
||||||
if (payload?.sender && payload.sender === getSseClientId()) return;
|
|
||||||
} catch { }
|
|
||||||
void brandAutomat.actions.load(true).catch(() => { });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default brandAutomat;
|
|
||||||
@@ -30,6 +30,7 @@ import { Automat } from 'react-automat';
|
|||||||
import { category as categoryApi, article as articleApi } from '../api';
|
import { category as categoryApi, article as articleApi } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
import companyAutomat from './companyAutomat';
|
import companyAutomat from './companyAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
import {
|
import {
|
||||||
attachCategoryTree,
|
attachCategoryTree,
|
||||||
categoryViews,
|
categoryViews,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
disposeAllCategoryViewAutomats,
|
disposeAllCategoryViewAutomats,
|
||||||
refreshCategoryViewChildren,
|
refreshCategoryViewChildren,
|
||||||
appendArticlesToCategoryViews,
|
appendArticlesToCategoryViews,
|
||||||
|
updateArticleInCategoryViews,
|
||||||
removeArticleBySku,
|
removeArticleBySku,
|
||||||
waitForCategoryTree,
|
waitForCategoryTree,
|
||||||
} from './categoryViewAutomat';
|
} from './categoryViewAutomat';
|
||||||
@@ -54,6 +56,7 @@ import {
|
|||||||
* @property {string|null} languageId
|
* @property {string|null} languageId
|
||||||
* @property {Record<string,object>|null} categories id → node map
|
* @property {Record<string,object>|null} categories id → node map
|
||||||
* @property {number|string|null} rootCategoryId
|
* @property {number|string|null} rootCategoryId
|
||||||
|
* @property {number} articleCount Total parent articles in tree
|
||||||
* @property {boolean} renameMode
|
* @property {boolean} renameMode
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
||||||
* @property {string|null} error
|
* @property {string|null} error
|
||||||
@@ -66,6 +69,7 @@ const initialState = {
|
|||||||
languageId: null,
|
languageId: null,
|
||||||
categories: null,
|
categories: null,
|
||||||
rootCategoryId: null,
|
rootCategoryId: null,
|
||||||
|
articleCount: 0,
|
||||||
renameMode: false,
|
renameMode: false,
|
||||||
treeStatus: 'idle',
|
treeStatus: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
@@ -87,6 +91,8 @@ export function getCategoryState() {
|
|||||||
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves once the category tree has loaded.
|
* Resolves once the category tree has loaded.
|
||||||
*
|
*
|
||||||
@@ -123,6 +129,7 @@ const actions = {
|
|||||||
languageId: parsed.languageId,
|
languageId: parsed.languageId,
|
||||||
categories: parsed.categories,
|
categories: parsed.categories,
|
||||||
rootCategoryId: parsed.rootCategoryId,
|
rootCategoryId: parsed.rootCategoryId,
|
||||||
|
articleCount: parsed.articleCount,
|
||||||
error: null,
|
error: null,
|
||||||
viewsLoading: true,
|
viewsLoading: true,
|
||||||
});
|
});
|
||||||
@@ -208,8 +215,8 @@ const actions = {
|
|||||||
* @param {{ parentId: number|string, name: string }} param0
|
* @param {{ parentId: number|string, name: string }} param0
|
||||||
*/
|
*/
|
||||||
async createArticle({ parentId, name }) {
|
async createArticle({ parentId, name }) {
|
||||||
const res = await articleApi.create({ categoryId: parentId, name });
|
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||||
const created = res.article;
|
const { article: created } = await articleApi.getBySku(sku);
|
||||||
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
|
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
@@ -223,8 +230,9 @@ const actions = {
|
|||||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||||
const created = [];
|
const created = [];
|
||||||
for (const name of list) {
|
for (const name of list) {
|
||||||
const res = await articleApi.create({ categoryId: parentId, name });
|
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||||
if (res.article) created.push(res.article);
|
const { article: created1 } = await articleApi.getBySku(sku);
|
||||||
|
if (created1) created.push(created1);
|
||||||
}
|
}
|
||||||
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
|
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
|
||||||
return created;
|
return created;
|
||||||
@@ -395,6 +403,28 @@ const actions = {
|
|||||||
return res.category;
|
return res.category;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a mosaic image for a category from its articles and subcategories.
|
||||||
|
* @param {{ categoryId: number|string, size?: number }} param0
|
||||||
|
*/
|
||||||
|
async generateCategoryImage({ categoryId, size } = {}) {
|
||||||
|
const res = await categoryApi.generateImage(categoryId, { size });
|
||||||
|
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
||||||
|
const { categories } = categoryAutomat.state;
|
||||||
|
if (!categories) return res.category;
|
||||||
|
const key = String(id);
|
||||||
|
const nextCategories = {
|
||||||
|
...categories,
|
||||||
|
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
|
||||||
|
};
|
||||||
|
categoryAutomat.setState({
|
||||||
|
categories: nextCategories,
|
||||||
|
});
|
||||||
|
refreshCategoryViewChildren(nextCategories);
|
||||||
|
return res.category;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ─── Local state patches ───────────────────────────────────────────────────
|
// ─── Local state patches ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Remove an article from all live category view lists (used after article deletion). */
|
/** Remove an article from all live category view lists (used after article deletion). */
|
||||||
@@ -424,6 +454,11 @@ const actions = {
|
|||||||
refreshCategoryViewChildren(nextCategories);
|
refreshCategoryViewChildren(nextCategories);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Invalidate all loaded category views. */
|
||||||
|
invalidateAllCategoryViews() {
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
},
|
||||||
|
|
||||||
/** Reset state to idle (logout / company switch). */
|
/** Reset state to idle (logout / company switch). */
|
||||||
reset() {
|
reset() {
|
||||||
disposeAllCategoryViewAutomats();
|
disposeAllCategoryViewAutomats();
|
||||||
@@ -451,9 +486,9 @@ categoryAutomat.views = categoryViews;
|
|||||||
|
|
||||||
// Load tree whenever the company data changes (or first becomes available)
|
// Load tree whenever the company data changes (or first becomes available)
|
||||||
let _lastCompanyId = null;
|
let _lastCompanyId = null;
|
||||||
companyAutomat.subscribe((company) => {
|
function syncCategoryWithCompany(company) {
|
||||||
const id = company.current?.id ?? null;
|
const id = company?.current?.id ?? null;
|
||||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
const rootCategoryId = company?.current?.rootCategoryId ?? null;
|
||||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||||
const isCompanySwitch = _lastCompanyId !== null;
|
const isCompanySwitch = _lastCompanyId !== null;
|
||||||
_lastCompanyId = id;
|
_lastCompanyId = id;
|
||||||
@@ -466,7 +501,10 @@ companyAutomat.subscribe((company) => {
|
|||||||
_lastCompanyId = null;
|
_lastCompanyId = null;
|
||||||
categoryAutomat.actions.reset();
|
categoryAutomat.actions.reset();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
companyAutomat.subscribe(syncCategoryWithCompany);
|
||||||
|
syncCategoryWithCompany(companyAutomat.state);
|
||||||
|
|
||||||
// Reset tree on logout
|
// Reset tree on logout
|
||||||
authAutomat.subscribe((auth) => {
|
authAutomat.subscribe((auth) => {
|
||||||
@@ -476,6 +514,14 @@ authAutomat.subscribe((auth) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upstream Trigger: sseAutomat
|
||||||
|
if (sseAutomat?.actions?.on) {
|
||||||
|
sseAutomat.actions.on('category', () => {
|
||||||
|
void categoryAutomat.actions.loadTree().catch(() => {});
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export pure helpers for consumers that previously imported from categorySlice
|
// Re-export pure helpers for consumers that previously imported from categorySlice
|
||||||
export {
|
export {
|
||||||
ROOT_CATEGORY_NAME,
|
ROOT_CATEGORY_NAME,
|
||||||
@@ -496,6 +542,7 @@ export {
|
|||||||
disposeAllCategoryViewAutomats,
|
disposeAllCategoryViewAutomats,
|
||||||
loadAllCategoryViews,
|
loadAllCategoryViews,
|
||||||
collectLoadedCategoryViewData,
|
collectLoadedCategoryViewData,
|
||||||
|
updateArticleInCategoryViews,
|
||||||
} from './categoryViewAutomat';
|
} from './categoryViewAutomat';
|
||||||
|
|
||||||
export default categoryAutomat;
|
export default categoryAutomat;
|
||||||
|
|||||||
@@ -323,6 +323,75 @@ export function appendArticlesToCategoryViews({ categoryId, articles }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an article in all loaded category views.
|
||||||
|
*
|
||||||
|
* @param {object} article Article detail or row with at least `sku`.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function updateArticleInCategoryViews(article) {
|
||||||
|
if (!article || article.sku == null) return;
|
||||||
|
const sku = String(article.sku);
|
||||||
|
const targetCategoryIds = Array.isArray(article.categoryIds)
|
||||||
|
? new Set(article.categoryIds.map(String))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const [slug, item] of categoryViews._items.entries()) {
|
||||||
|
if (!item?.data) continue;
|
||||||
|
const viewCatId = String(item.data.categoryId);
|
||||||
|
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
|
||||||
|
// Article moved out of this category
|
||||||
|
categoryViews.key(slug).set({
|
||||||
|
...item.data,
|
||||||
|
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update in-place
|
||||||
|
const existing = item.data.articles[existingIndex];
|
||||||
|
const updatedArticles = [...item.data.articles];
|
||||||
|
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||||
|
const hasPhotosProp = 'photos' in article;
|
||||||
|
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||||
|
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||||
|
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||||
|
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||||
|
|
||||||
|
updatedArticles[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
...article,
|
||||||
|
name: article.name ?? existing.name,
|
||||||
|
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
|
||||||
|
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
|
||||||
|
stock: article.stock ?? existing.stock,
|
||||||
|
available: article.available ?? existing.available,
|
||||||
|
warehouses: article.warehouses ?? existing.warehouses,
|
||||||
|
brand: article.brand ?? existing.brand,
|
||||||
|
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||||
|
childCount: article.childCount ?? existing.childCount,
|
||||||
|
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
|
||||||
|
fullHash,
|
||||||
|
thumbHash,
|
||||||
|
photos,
|
||||||
|
smallThumbBinary,
|
||||||
|
};
|
||||||
|
categoryViews.key(slug).set({
|
||||||
|
...item.data,
|
||||||
|
articles: updatedArticles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
|
||||||
|
// Article added to this category
|
||||||
|
categoryViews.key(slug).set({
|
||||||
|
...item.data,
|
||||||
|
articles: [...(item.data.articles || []), article],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a deleted article from every loaded view that contains it.
|
* Removes a deleted article from every loaded view that contains it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ const initialState = {
|
|||||||
draftConversation: false,
|
draftConversation: false,
|
||||||
/** Prefilled input text waiting to be consumed by the chat UI. */
|
/** Prefilled input text waiting to be consumed by the chat UI. */
|
||||||
pendingInput: null,
|
pendingInput: null,
|
||||||
|
/** Staged text extraction snippet for data extraction pill. */
|
||||||
|
pendingExtraction: null,
|
||||||
conversationState: 'idle',
|
conversationState: 'idle',
|
||||||
provider: 'openrouter',
|
provider: 'openrouter',
|
||||||
model: DEFAULT_MODEL,
|
model: DEFAULT_MODEL,
|
||||||
@@ -283,6 +285,17 @@ function closeConversationStream() {
|
|||||||
}
|
}
|
||||||
activeStreamConversationId = null;
|
activeStreamConversationId = null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Checks if a tool call or stream tool event represents an interactive tool
|
||||||
|
* that awaits user intervention (ask_user).
|
||||||
|
* @param {object} item
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInteractiveTool(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
const name = item.name || item.function?.name;
|
||||||
|
return name === 'ask_user';
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
@@ -327,6 +340,55 @@ const actions = {
|
|||||||
void chatAutomat.actions.loadConversations();
|
void chatAutomat.actions.loadConversations();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill.
|
||||||
|
* @param {{ text: string, label?: string, instruction?: string }} snippet
|
||||||
|
*/
|
||||||
|
insertExtractionSnippet({
|
||||||
|
text = '',
|
||||||
|
label = 'parsed invoice',
|
||||||
|
instruction = "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
} = {}) {
|
||||||
|
const trimmed = String(text || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
chatOpenedThisSession = true;
|
||||||
|
chatAutomat.setState({
|
||||||
|
isOpen: true,
|
||||||
|
sseEstablished: true,
|
||||||
|
pendingExtraction: {
|
||||||
|
text: trimmed,
|
||||||
|
label: label || 'parsed invoice',
|
||||||
|
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ensureCompanyEventsConnected();
|
||||||
|
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
|
||||||
|
chatAutomat.actions.startDraft();
|
||||||
|
}
|
||||||
|
void chatAutomat.actions.loadConversations();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the chat drawer and append text / prompt to the chat composer.
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
insertPrompt(prompt = '') {
|
||||||
|
const text = String(prompt || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
chatOpenedThisSession = true;
|
||||||
|
chatAutomat.setState((prev) => ({
|
||||||
|
isOpen: true,
|
||||||
|
sseEstablished: true,
|
||||||
|
pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${text}` : text,
|
||||||
|
}));
|
||||||
|
ensureCompanyEventsConnected();
|
||||||
|
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
|
||||||
|
chatAutomat.actions.startDraft();
|
||||||
|
}
|
||||||
|
void chatAutomat.actions.loadConversations();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start an implicit new conversation with the first preset model. Nothing is
|
* Start an implicit new conversation with the first preset model. Nothing is
|
||||||
* created on the server until the first message is sent.
|
* created on the server until the first message is sent.
|
||||||
@@ -467,13 +529,18 @@ const actions = {
|
|||||||
|
|
||||||
const messages = msgsRes?.messages || [];
|
const messages = msgsRes?.messages || [];
|
||||||
const updatedConv = convRes?.conversation;
|
const updatedConv = convRes?.conversation;
|
||||||
|
const hasPendingInteractive = Boolean(
|
||||||
|
selectPendingAskUser({ messages })
|
||||||
|
);
|
||||||
|
|
||||||
chatAutomat.setState((prev) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages,
|
messages,
|
||||||
loadingMessages: false,
|
loadingMessages: false,
|
||||||
conversationState: updatedConv?.state || prev.conversationState,
|
conversationState: hasPendingInteractive ? 'idle' : (updatedConv?.state || prev.conversationState),
|
||||||
|
streamPhase: hasPendingInteractive ? 'idle' : prev.streamPhase,
|
||||||
|
isSending: hasPendingInteractive ? false : prev.isSending,
|
||||||
conversations: updatedConv
|
conversations: updatedConv
|
||||||
? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c))
|
? prev.conversations.map((c) => (c.id === updatedConv.id ? (hasPendingInteractive ? { ...updatedConv, state: 'idle' } : updatedConv) : c))
|
||||||
: prev.conversations,
|
: prev.conversations,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -830,7 +897,7 @@ const actions = {
|
|||||||
const updatedTool = {
|
const updatedTool = {
|
||||||
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
state: 'green',
|
state: 'green',
|
||||||
name: data.name || '',
|
name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
|
||||||
arguments: data.arguments,
|
arguments: data.arguments,
|
||||||
content: data.content !== undefined ? data.content : data.arguments || '',
|
content: data.content !== undefined ? data.content : data.arguments || '',
|
||||||
result: data.result,
|
result: data.result,
|
||||||
@@ -844,12 +911,19 @@ const actions = {
|
|||||||
lastTurn.tools = tools;
|
lastTurn.tools = tools;
|
||||||
turns[lastIdx] = lastTurn;
|
turns[lastIdx] = lastTurn;
|
||||||
|
|
||||||
const hasAskUser = data.name === 'ask_user';
|
const isInteractive = isInteractiveTool(data) || isInteractiveTool(updatedTool);
|
||||||
|
const nextConversations = isInteractive
|
||||||
|
? prev.conversations.map((c) =>
|
||||||
|
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
||||||
|
)
|
||||||
|
: prev.conversations;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTurns: turns,
|
activeTurns: turns,
|
||||||
streamPhase: hasAskUser ? 'idle' : 'working',
|
streamPhase: isInteractive ? 'idle' : 'working',
|
||||||
conversationState: hasAskUser ? 'idle' : prev.conversationState,
|
conversationState: isInteractive ? 'idle' : prev.conversationState,
|
||||||
isSending: hasAskUser ? false : prev.isSending,
|
isSending: isInteractive ? false : prev.isSending,
|
||||||
|
conversations: nextConversations,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1146,10 +1220,10 @@ const actions = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
|
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
|
||||||
* Body: { role: 'tool', tool_call_id, content, answer }
|
* Body: { role: 'tool', tool_call_id, name, content, answer }
|
||||||
* @param {{ tool_call_id: string, answer: string }} params
|
* @param {{ tool_call_id: string, answer?: string, name?: string, content?: any }} params
|
||||||
*/
|
*/
|
||||||
async sendToolResponse({ tool_call_id, answer }) {
|
async sendToolResponse({ tool_call_id, answer, name = 'ask_user', content = undefined }) {
|
||||||
const { selectedConversationId, isSending } = chatAutomat.state;
|
const { selectedConversationId, isSending } = chatAutomat.state;
|
||||||
if (!selectedConversationId) {
|
if (!selectedConversationId) {
|
||||||
throw new Error('Keine Unterhaltung ausgewählt');
|
throw new Error('Keine Unterhaltung ausgewählt');
|
||||||
@@ -1158,12 +1232,13 @@ const actions = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolContent = content !== undefined ? content : answer;
|
||||||
const payload = {
|
const payload = {
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
name: 'ask_user',
|
name,
|
||||||
content: answer,
|
content: toolContent,
|
||||||
answer,
|
...(answer !== undefined ? { answer } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
@@ -1207,7 +1282,7 @@ const actions = {
|
|||||||
if (m.role === 'assistant' && !m.turns) {
|
if (m.role === 'assistant' && !m.turns) {
|
||||||
try {
|
try {
|
||||||
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
|
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
|
||||||
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) {
|
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === name || tc.function?.name === 'ask_user')) {
|
||||||
foundExisting = true;
|
foundExisting = true;
|
||||||
return { ...m, turns: sealedTurns };
|
return { ...m, turns: sealedTurns };
|
||||||
}
|
}
|
||||||
@@ -1316,12 +1391,12 @@ const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant' || message.role === 'model';
|
const isAssistant = message.role === 'assistant' || message.role === 'model';
|
||||||
let hasAskUser = false;
|
let hasInteractiveTool = false;
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
try {
|
try {
|
||||||
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
|
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
|
||||||
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) {
|
if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
|
||||||
hasAskUser = true;
|
hasInteractiveTool = true;
|
||||||
}
|
}
|
||||||
if (Array.isArray(payload?.tool_calls)) {
|
if (Array.isArray(payload?.tool_calls)) {
|
||||||
for (const tc of payload.tool_calls) {
|
for (const tc of payload.tool_calls) {
|
||||||
@@ -1360,9 +1435,9 @@ const actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
|
||||||
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase;
|
const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
|
||||||
const nextConversations = hasAskUser
|
const nextConversations = hasInteractiveTool
|
||||||
? prev.conversations.map((c) =>
|
? prev.conversations.map((c) =>
|
||||||
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
||||||
)
|
)
|
||||||
@@ -1374,6 +1449,7 @@ const actions = {
|
|||||||
conversationState: nextConversationState,
|
conversationState: nextConversationState,
|
||||||
streamPhase: nextStreamPhase,
|
streamPhase: nextStreamPhase,
|
||||||
conversations: nextConversations,
|
conversations: nextConversations,
|
||||||
|
isSending: hasInteractiveTool ? false : prev.isSending,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1534,6 +1610,7 @@ export function selectPendingAskUser(state = chatAutomat.state) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state));
|
export const selectHasPendingAsk = (state = chatAutomat.state) =>
|
||||||
|
Boolean(selectPendingAskUser(state));
|
||||||
|
|
||||||
export default chatAutomat;
|
export default chatAutomat;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
928
src/automats/createChunkListAutomat.js
Normal file
928
src/automats/createChunkListAutomat.js
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
/**
|
||||||
|
* @file createChunkListAutomat.js
|
||||||
|
* @description Factory function for company-scoped, chunk-cached double-buffered
|
||||||
|
* list automats with LRU memory caching, debounced prefetching, and multi-filter
|
||||||
|
* retention (like `orderAutomat` and `quoteAutomat`).
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes each created automat to auth state — resets on `'anon'`
|
||||||
|
* or when `selectedCompanyId` changes.
|
||||||
|
* - Consumer UI (e.g. `FillSpace`): Calls `getItems()` and `requireChunk(start, end)`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
|
export const DEFAULT_CHUNK_SIZE = 50;
|
||||||
|
export const DEFAULT_MAX_LRU_CHUNKS = 50;
|
||||||
|
const PREFETCH_CHUNKS = 1;
|
||||||
|
const CHUNK_DEBOUNCE_MS = 130;
|
||||||
|
const MAX_CHUNK_EVENTS = 150;
|
||||||
|
|
||||||
|
export function formatNegativeSeek(months) {
|
||||||
|
if (!months || months >= 0) return '';
|
||||||
|
const total = Math.abs(months);
|
||||||
|
const years = Math.floor(total / 12);
|
||||||
|
const remMonths = total % 12;
|
||||||
|
const parts = [];
|
||||||
|
if (years > 0) {
|
||||||
|
parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`);
|
||||||
|
}
|
||||||
|
if (remMonths > 0) {
|
||||||
|
parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`);
|
||||||
|
}
|
||||||
|
return `-${parts.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a chunk-paginated, double-buffered automat.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.name Name for the Automat instance.
|
||||||
|
* @param {(params: { offset: number, limit: number, search?: string, seekMonths?: number }) => Promise<{ items?: any[], history?: any[], rows?: any[], entries?: any[], total?: number, unfilteredTotal?: number }>} options.fetchPage
|
||||||
|
* @param {number} [options.chunkSize=50]
|
||||||
|
* @param {number} [options.maxLruChunks=50]
|
||||||
|
* @returns {Automat} Configured chunk-list automat.
|
||||||
|
*/
|
||||||
|
export function createChunkListAutomat({
|
||||||
|
name,
|
||||||
|
fetchPage,
|
||||||
|
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||||
|
maxLruChunks = DEFAULT_MAX_LRU_CHUNKS,
|
||||||
|
}) {
|
||||||
|
const initialState = {
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: 0,
|
||||||
|
filter: '',
|
||||||
|
seekMonths: 0,
|
||||||
|
seekYears: 0,
|
||||||
|
negativeSeekMonths: 0,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let automat;
|
||||||
|
let queryEpoch = 0;
|
||||||
|
let filterDebounceTimer = null;
|
||||||
|
let chunkDebounceTimer = null;
|
||||||
|
let currentEventTick = 0;
|
||||||
|
let lastRequireChunkTick = -1;
|
||||||
|
|
||||||
|
const pendingTargetChunks = new Set();
|
||||||
|
let pendingChunkPromise = null;
|
||||||
|
let pendingChunkResolve = null;
|
||||||
|
let pendingChunkReject = null;
|
||||||
|
|
||||||
|
// Multi-Filter Cache & Global LRU Management
|
||||||
|
const filterCaches = new Map();
|
||||||
|
const lruChunks = new Map();
|
||||||
|
const inflightChunks = new Map();
|
||||||
|
|
||||||
|
// Observability & Stats
|
||||||
|
let nextEventId = 0;
|
||||||
|
const chunkEvents = [];
|
||||||
|
const chunkStatsListeners = new Set();
|
||||||
|
|
||||||
|
function makeLruKey(filterKey, chunkIndex) {
|
||||||
|
return `${filterKey}:::${chunkIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLruKey(key) {
|
||||||
|
const [filterKey, chunkStr] = key.split(':::');
|
||||||
|
return { filterKey, chunkIndex: Number(chunkStr) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterParams(state = automat?.state || initialState) {
|
||||||
|
const filter = String(state.filter ?? '').trim();
|
||||||
|
const negativeSeekMonths =
|
||||||
|
state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0
|
||||||
|
? Number(state.negativeSeekMonths)
|
||||||
|
: 0;
|
||||||
|
return { filter, negativeSeekMonths };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterKey(state = automat?.state || initialState) {
|
||||||
|
const { filter, negativeSeekMonths } = getFilterParams(state);
|
||||||
|
return JSON.stringify({ f: filter, s: negativeSeekMonths });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilterLabel(filterKeyOrState) {
|
||||||
|
let f, s;
|
||||||
|
if (typeof filterKeyOrState === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(filterKeyOrState);
|
||||||
|
f = parsed.f;
|
||||||
|
s = parsed.s;
|
||||||
|
} catch {
|
||||||
|
return filterKeyOrState;
|
||||||
|
}
|
||||||
|
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
|
||||||
|
if ('f' in filterKeyOrState) {
|
||||||
|
f = filterKeyOrState.f;
|
||||||
|
s = filterKeyOrState.s;
|
||||||
|
} else {
|
||||||
|
const params = getFilterParams(filterKeyOrState);
|
||||||
|
f = params.filter;
|
||||||
|
s = params.negativeSeekMonths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (f) parts.push(`Suche: „${f}“`);
|
||||||
|
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
|
||||||
|
if (parts.length === 0) return 'Standard (Alle Einträge)';
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNonNullItems(items) {
|
||||||
|
if (!Array.isArray(items)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i] != null) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) {
|
||||||
|
let entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) {
|
||||||
|
const params = getFilterParams(filterState);
|
||||||
|
entry = {
|
||||||
|
key: filterKey,
|
||||||
|
label: formatFilterLabel(params),
|
||||||
|
params,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat?.state?.unfilteredTotal || 0,
|
||||||
|
items: null,
|
||||||
|
loadedChunks: new Set(),
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
lastAccessed: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
};
|
||||||
|
filterCaches.set(filterKey, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchChunkLRU(filterKey, chunkIndex) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
const existing = lruChunks.get(key);
|
||||||
|
if (existing) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
lruChunks.set(key, { ...existing, accessedAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerChunkLRU(filterKey, chunkIndex, itemCount = 0) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
if (lruChunks.has(key)) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
lruChunks.set(key, {
|
||||||
|
filterKey,
|
||||||
|
chunkIndex,
|
||||||
|
loadedAt: now,
|
||||||
|
accessedAt: now,
|
||||||
|
itemCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
while (lruChunks.size > maxLruChunks) {
|
||||||
|
const oldestKey = lruChunks.keys().next().value;
|
||||||
|
const oldestInfo = lruChunks.get(oldestKey);
|
||||||
|
lruChunks.delete(oldestKey);
|
||||||
|
if (oldestInfo) {
|
||||||
|
evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') {
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (entry.loadedChunks.has(chunkIndex)) {
|
||||||
|
entry.loadedChunks.delete(chunkIndex);
|
||||||
|
if (Array.isArray(entry.items)) {
|
||||||
|
const offset = chunkIndex * chunkSize;
|
||||||
|
for (let i = offset; i < offset + chunkSize && i < entry.items.length; i++) {
|
||||||
|
entry.items[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
if (filterKey === currentKey) {
|
||||||
|
automat.setState({ items: [...entry.items] });
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: reason === 'lru' ? 'lru_evict' : 'evict',
|
||||||
|
chunkIndex,
|
||||||
|
filterKey,
|
||||||
|
message:
|
||||||
|
reason === 'lru'
|
||||||
|
? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`
|
||||||
|
: `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordChunkEvent(entry) {
|
||||||
|
const event = {
|
||||||
|
id: ++nextEventId,
|
||||||
|
time: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: entry.filterKey ?? getFilterKey(automat?.state),
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
chunkEvents.push(event);
|
||||||
|
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
|
||||||
|
chunkEvents.shift();
|
||||||
|
}
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChunkStats() {
|
||||||
|
if (chunkStatsListeners.size === 0) return;
|
||||||
|
const stats = getChunkStats();
|
||||||
|
for (const listener of chunkStatsListeners) {
|
||||||
|
try {
|
||||||
|
listener(stats);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${name}] chunkStatsListener error`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChunkStats() {
|
||||||
|
const currentKey = getFilterKey(automat?.state);
|
||||||
|
const activeEntry = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
const total = activeEntry?.total ?? automat?.state?.total ?? 0;
|
||||||
|
const activeLoaded = activeEntry?.loadedChunks ?? new Set();
|
||||||
|
const count = total > 0 ? Math.ceil(total / chunkSize) : 0;
|
||||||
|
|
||||||
|
const activeInflight = [];
|
||||||
|
for (const key of inflightChunks.keys()) {
|
||||||
|
const info = parseLruKey(key);
|
||||||
|
if (info.filterKey === currentKey) {
|
||||||
|
activeInflight.push(info.chunkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = activeEntry?.items ?? automat?.state?.items ?? null;
|
||||||
|
const loadedItemsCount = countNonNullItems(items);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunkSize,
|
||||||
|
maxLruChunks,
|
||||||
|
lruChunksCount: lruChunks.size,
|
||||||
|
total,
|
||||||
|
unfilteredTotal: activeEntry?.unfilteredTotal ?? automat?.state?.unfilteredTotal ?? total,
|
||||||
|
chunkCount: count,
|
||||||
|
loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b),
|
||||||
|
inflightChunks: activeInflight.sort((a, b) => a - b),
|
||||||
|
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
|
||||||
|
loadedItemsCount,
|
||||||
|
status: automat?.state?.status ?? 'idle',
|
||||||
|
queryEpoch,
|
||||||
|
filter: automat?.state?.filter ?? '',
|
||||||
|
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
|
||||||
|
items,
|
||||||
|
activeFilterKey: currentKey,
|
||||||
|
activeFilterLabel: formatFilterLabel(currentKey),
|
||||||
|
events: [...chunkEvents],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeChunkStats(listener) {
|
||||||
|
chunkStatsListeners.add(listener);
|
||||||
|
try {
|
||||||
|
listener(getChunkStats());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
chunkStatsListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChunkDebounce() {
|
||||||
|
if (chunkDebounceTimer) {
|
||||||
|
clearTimeout(chunkDebounceTimer);
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (pendingTargetChunks.size > 0) {
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
pendingChunkPromise = null;
|
||||||
|
pendingChunkResolve = null;
|
||||||
|
pendingChunkReject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _executeRequiredChunks(
|
||||||
|
chunks,
|
||||||
|
targetEpoch,
|
||||||
|
prefetch = true,
|
||||||
|
targetFilterKey = getFilterKey(automat.state)
|
||||||
|
) {
|
||||||
|
if (targetEpoch !== queryEpoch) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkList = Array.from(chunks).sort((a, b) => a - b);
|
||||||
|
if (chunkList.length === 0) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'execute',
|
||||||
|
chunks: chunkList,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (const c of chunkList) {
|
||||||
|
promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises).then(() => {
|
||||||
|
if (targetEpoch !== queryEpoch) return automat.state.items;
|
||||||
|
if (prefetch) {
|
||||||
|
const maxChunk = chunkList[chunkList.length - 1];
|
||||||
|
const entry = filterCaches.get(targetFilterKey);
|
||||||
|
const total = entry?.total || automat.state.total || 0;
|
||||||
|
for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
|
||||||
|
if (total > 0 && c * chunkSize >= total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'prefetch',
|
||||||
|
chunkIndex: c,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`,
|
||||||
|
});
|
||||||
|
void actions.loadChunk(c, targetEpoch, targetFilterKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return automat.state.items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParamsFor(filterParams, offset = 0, limit = chunkSize) {
|
||||||
|
const params = { offset, limit };
|
||||||
|
const trimmed = String(filterParams.filter ?? '').trim();
|
||||||
|
if (trimmed) params.search = trimmed;
|
||||||
|
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
|
||||||
|
params.seekMonths = Number(filterParams.negativeSeekMonths);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecordsPageFor(filterParams, offset = 0, limit = chunkSize) {
|
||||||
|
const params = buildQueryParamsFor(filterParams, offset, limit);
|
||||||
|
const res = await fetchPage(params);
|
||||||
|
const records = Array.isArray(res?.items)
|
||||||
|
? res.items
|
||||||
|
: Array.isArray(res?.history)
|
||||||
|
? res.history
|
||||||
|
: Array.isArray(res?.ledger)
|
||||||
|
? res.ledger
|
||||||
|
: Array.isArray(res?.rows)
|
||||||
|
? res.rows
|
||||||
|
: Array.isArray(res?.entries)
|
||||||
|
? res.entries
|
||||||
|
: Array.isArray(res)
|
||||||
|
? res
|
||||||
|
: [];
|
||||||
|
const total = Number(res?.total ?? res?.totalRows ?? res?.totalCount ?? records.length);
|
||||||
|
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||||
|
return { records, total, unfilteredTotal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(immediate = false) {
|
||||||
|
if (filterDebounceTimer) {
|
||||||
|
clearTimeout(filterDebounceTimer);
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
applyFilterReload();
|
||||||
|
} else {
|
||||||
|
filterDebounceTimer = setTimeout(() => {
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
applyFilterReload();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilterReload() {
|
||||||
|
queryEpoch++;
|
||||||
|
clearChunkDebounce();
|
||||||
|
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
const cached = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
if (cached && cached.items !== null) {
|
||||||
|
cached.lastAccessed = Date.now();
|
||||||
|
for (const c of cached.loadedChunks) {
|
||||||
|
touchChunkLRU(currentKey, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: cached.items,
|
||||||
|
total: cached.total,
|
||||||
|
unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: cached.status || 'ready',
|
||||||
|
error: cached.error || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_hit',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Einträge im RAM)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
if (!cached.loadedChunks.has(0)) {
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_miss',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
getItems() {
|
||||||
|
if (automat.state.items === null) {
|
||||||
|
void actions.requireChunk(0, 0, { prefetch: false, immediate: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
requireChunk(chunkIndex, endOrOptions = chunkIndex, maybeOptions = {}) {
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
let options = {};
|
||||||
|
|
||||||
|
if (Array.isArray(chunkIndex)) {
|
||||||
|
start = chunkIndex[0] ?? 0;
|
||||||
|
end = chunkIndex[chunkIndex.length - 1] ?? start;
|
||||||
|
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
|
||||||
|
options = endOrOptions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start = Number(chunkIndex) || 0;
|
||||||
|
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
|
||||||
|
end = start;
|
||||||
|
options = endOrOptions;
|
||||||
|
} else {
|
||||||
|
end = Number(endOrOptions ?? start) || start;
|
||||||
|
if (typeof maybeOptions === 'object' && maybeOptions !== null) {
|
||||||
|
options = maybeOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = Math.max(0, Math.floor(start));
|
||||||
|
end = Math.max(start, Math.floor(end));
|
||||||
|
const prefetch = options.prefetch !== false;
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
|
||||||
|
if (options.immediate || automat.state.items === null) {
|
||||||
|
clearChunkDebounce();
|
||||||
|
const chunks = new Set();
|
||||||
|
for (let c = start; c <= end; c++) chunks.add(c);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'require_immediate',
|
||||||
|
chunks: Array.from(chunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`,
|
||||||
|
});
|
||||||
|
return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastRequireChunkTick !== currentEventTick) {
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
lastRequireChunkTick = currentEventTick;
|
||||||
|
if (typeof queueMicrotask === 'function') {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
currentEventTick++;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
currentEventTick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = start; c <= end; c++) {
|
||||||
|
pendingTargetChunks.add(c);
|
||||||
|
}
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'pending',
|
||||||
|
chunks: Array.from(pendingTargetChunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
if (chunkDebounceTimer) {
|
||||||
|
clearTimeout(chunkDebounceTimer);
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingChunkPromise) {
|
||||||
|
pendingChunkPromise = new Promise((resolve, reject) => {
|
||||||
|
pendingChunkResolve = resolve;
|
||||||
|
pendingChunkReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureEpoch = queryEpoch;
|
||||||
|
chunkDebounceTimer = setTimeout(() => {
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
const chunksToLoad = new Set(pendingTargetChunks);
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
const resolve = pendingChunkResolve;
|
||||||
|
const reject = pendingChunkReject;
|
||||||
|
pendingChunkPromise = null;
|
||||||
|
pendingChunkResolve = null;
|
||||||
|
pendingChunkReject = null;
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'debounce_fire',
|
||||||
|
chunks: Array.from(chunksToLoad),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
_executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey)
|
||||||
|
.then((res) => resolve?.(res))
|
||||||
|
.catch((err) => reject?.(err));
|
||||||
|
}, CHUNK_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return pendingChunkPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
|
||||||
|
const index = Math.max(0, Number(chunkIndex) || 0);
|
||||||
|
const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
|
||||||
|
|
||||||
|
if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) {
|
||||||
|
return Promise.resolve(entry.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.loadedChunks.has(index)) {
|
||||||
|
touchChunkLRU(targetFilterKey, index);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`,
|
||||||
|
});
|
||||||
|
return Promise.resolve(
|
||||||
|
targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightKey = makeLruKey(targetFilterKey, index);
|
||||||
|
if (inflightChunks.has(inflightKey)) {
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'inflight_hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`,
|
||||||
|
});
|
||||||
|
return inflightChunks.get(inflightKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = index * chunkSize;
|
||||||
|
const limit = chunkSize;
|
||||||
|
const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
|
||||||
|
if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
|
||||||
|
entry.status = 'loading';
|
||||||
|
entry.error = null;
|
||||||
|
if (isActiveFilter) {
|
||||||
|
automat.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'start',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterParams = entry.params || getFilterParams(automat.state);
|
||||||
|
const promise = fetchRecordsPageFor(filterParams, offset, limit)
|
||||||
|
.then(({ records, total, unfilteredTotal }) => {
|
||||||
|
const currentTotal = total > 0 ? total : 0;
|
||||||
|
entry.total = currentTotal;
|
||||||
|
entry.unfilteredTotal = unfilteredTotal;
|
||||||
|
|
||||||
|
if (entry.items === null || entry.items.length !== currentTotal) {
|
||||||
|
entry.items = new Array(currentTotal).fill(null);
|
||||||
|
} else {
|
||||||
|
entry.items = [...entry.items];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const idx = offset + i;
|
||||||
|
if (idx < entry.items.length) {
|
||||||
|
entry.items[idx] = records[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.loadedChunks.add(index);
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
registerChunkLRU(targetFilterKey, index, records.length);
|
||||||
|
|
||||||
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
if (currentActive) {
|
||||||
|
automat.setState({
|
||||||
|
items: entry.items,
|
||||||
|
total: currentTotal,
|
||||||
|
unfilteredTotal,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'loaded',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
count: records.length,
|
||||||
|
duration,
|
||||||
|
message: `Chunk #${index} geladen (${records.length} Einträge in ${duration}ms) für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentActive ? automat.state.items : entry.items;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
entry.status = 'failed';
|
||||||
|
entry.error = err;
|
||||||
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
if (currentActive) {
|
||||||
|
if (index === 0 && automat.state.items === null) {
|
||||||
|
automat.setState({ status: 'failed', error: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error(`[${name}] loadChunk(${index}, ${targetFilterKey}) error:`, err);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'error',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
duration,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightChunks.delete(inflightKey);
|
||||||
|
notifyChunkStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
inflightChunks.set(inflightKey, promise);
|
||||||
|
notifyChunkStats();
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
filter(query) {
|
||||||
|
automat.setState({ filter: String(query ?? '') });
|
||||||
|
onFilterChange(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
addSeekMonth() {
|
||||||
|
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
|
||||||
|
const seekYears = automat.state.seekYears ?? 0;
|
||||||
|
const totalMonths = -(seekMonths + seekYears * 12);
|
||||||
|
automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
addSeekYear() {
|
||||||
|
const seekMonths = automat.state.seekMonths ?? 0;
|
||||||
|
const seekYears = (automat.state.seekYears ?? 0) + 1;
|
||||||
|
const totalMonths = -(seekMonths + seekYears * 12);
|
||||||
|
automat.setState({ seekYears, negativeSeekMonths: totalMonths });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
addNegativeSeek(months) {
|
||||||
|
if (months === -12) {
|
||||||
|
actions.addSeekYear();
|
||||||
|
} else if (months === -1) {
|
||||||
|
actions.addSeekMonth();
|
||||||
|
} else {
|
||||||
|
const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
|
||||||
|
const next = current + months;
|
||||||
|
automat.setState({ negativeSeekMonths: Math.min(0, next) });
|
||||||
|
onFilterChange(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetNegativeSeek() {
|
||||||
|
automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
automat.setState({
|
||||||
|
filter: '',
|
||||||
|
seekMonths: 0,
|
||||||
|
seekYears: 0,
|
||||||
|
negativeSeekMonths: 0,
|
||||||
|
});
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
load() {
|
||||||
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureLoaded() {
|
||||||
|
if (automat.state.items !== null) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
},
|
||||||
|
|
||||||
|
async reload(onlyCurrent = false) {
|
||||||
|
queryEpoch++;
|
||||||
|
clearChunkDebounce();
|
||||||
|
|
||||||
|
if (onlyCurrent) {
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
actions.clearFilterCache(currentKey);
|
||||||
|
} else {
|
||||||
|
actions.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
replace(items) {
|
||||||
|
clearChunkDebounce();
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
actions.clearFilterCache(currentKey);
|
||||||
|
|
||||||
|
const count = Array.isArray(items) ? items.length : 0;
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.items = items;
|
||||||
|
entry.total = count;
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
|
||||||
|
const chunkCount = count > 0 ? Math.ceil(count / chunkSize) : 0;
|
||||||
|
for (let c = 0; c < chunkCount; c++) {
|
||||||
|
entry.loadedChunks.add(c);
|
||||||
|
registerChunkLRU(currentKey, c, Math.min(chunkSize, count - c * chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items,
|
||||||
|
total: count,
|
||||||
|
unfilteredTotal: count,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilterCache(filterKey) {
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (entry) {
|
||||||
|
for (const c of entry.loadedChunks) {
|
||||||
|
lruChunks.delete(makeLruKey(filterKey, c));
|
||||||
|
}
|
||||||
|
entry.items = null;
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
|
||||||
|
if (filterKey === getFilterKey(automat.state)) {
|
||||||
|
automat.setState({ items: null, status: 'idle', error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear_filter',
|
||||||
|
filterKey,
|
||||||
|
message: `Filter-Cache "${entry.label}" geleert`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
clearChunkDebounce();
|
||||||
|
lruChunks.clear();
|
||||||
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
for (const entry of filterCaches.values()) {
|
||||||
|
entry.items = null;
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear_all',
|
||||||
|
message: `Gesamter ${name}-Chunk-Cache geleert`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
if (filterDebounceTimer) {
|
||||||
|
clearTimeout(filterDebounceTimer);
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
clearChunkDebounce();
|
||||||
|
filterCaches.clear();
|
||||||
|
lruChunks.clear();
|
||||||
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'reset',
|
||||||
|
message: `${name}-Automat und sämtliche Caches zurückgesetzt`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
automat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(initialState, actions, { name });
|
||||||
|
|
||||||
|
automat.getItems = actions.getItems;
|
||||||
|
automat.requireChunk = actions.requireChunk;
|
||||||
|
automat.CHUNK_SIZE = chunkSize;
|
||||||
|
automat.MAX_LRU_CHUNKS = maxLruChunks;
|
||||||
|
automat.getChunkStats = getChunkStats;
|
||||||
|
automat.subscribeChunkStats = subscribeChunkStats;
|
||||||
|
automat.getFilterKey = getFilterKey;
|
||||||
|
automat.formatFilterLabel = formatFilterLabel;
|
||||||
|
|
||||||
|
// Reset on logout or company change
|
||||||
|
let lastCompanyId = null;
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId ?? null;
|
||||||
|
if (auth.status === 'anon') {
|
||||||
|
actions.reset();
|
||||||
|
lastCompanyId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastCompanyId !== null && companyId !== lastCompanyId) {
|
||||||
|
actions.reset();
|
||||||
|
}
|
||||||
|
if (auth.status === 'authed' && companyId != null) {
|
||||||
|
lastCompanyId = companyId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file createListAutomat.js
|
|
||||||
* @description Factory function for company-scoped list automats with
|
|
||||||
* deduplicated `load()`, silent `reload()`, and automatic session synchronization.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes each created list automat to auth state — calls
|
|
||||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
*
|
|
||||||
* Used by:
|
|
||||||
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
|
||||||
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
|
|
||||||
*
|
|
||||||
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
|
|
||||||
* category views rather than fetched from a whole-company endpoint.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} ListAutomatState
|
|
||||||
* @property {Array<object>} items Loaded records (empty until the first success).
|
|
||||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
|
||||||
* @property {Error|string|null} error Last load error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a company-scoped list store following the brand/unit/warehouse
|
|
||||||
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
|
||||||
* silently, and `reset()` clears on logout.
|
|
||||||
*
|
|
||||||
* This replaces the stale-while-revalidate list caches that previously lived
|
|
||||||
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
|
||||||
* customergroup, number).
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
* @param {object} options Factory options.
|
|
||||||
* @param {string} options.name Automat registry name (also used for logging).
|
|
||||||
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
|
||||||
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
|
||||||
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
|
||||||
*/
|
|
||||||
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
|
||||||
const initialState = { items: [], status: 'idle', error: null };
|
|
||||||
|
|
||||||
let automat;
|
|
||||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
|
||||||
let inflightLoad = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and normalizes the record list.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<T>>} Records from the backend.
|
|
||||||
*/
|
|
||||||
const fetchRecords = async () => {
|
|
||||||
const items = await fetchItems();
|
|
||||||
return sort ? sort(items) : items;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/**
|
|
||||||
* Loads the list, deduplicating concurrent callers.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<T>>} Loaded records.
|
|
||||||
*/
|
|
||||||
load() {
|
|
||||||
if (inflightLoad) return inflightLoad;
|
|
||||||
inflightLoad = (async () => {
|
|
||||||
automat.setState({ status: 'loading', error: null });
|
|
||||||
try {
|
|
||||||
const items = await fetchRecords();
|
|
||||||
automat.setState({ items, status: 'ready', error: null });
|
|
||||||
return items;
|
|
||||||
} catch (error) {
|
|
||||||
automat.setState({ status: 'failed', error });
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
inflightLoad = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return inflightLoad;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silently re-fetches the list while keeping the current state, so
|
|
||||||
* subscribers do not flash a loading state.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<T>>} Fresh records.
|
|
||||||
*/
|
|
||||||
async reload() {
|
|
||||||
try {
|
|
||||||
const items = await fetchRecords();
|
|
||||||
automat.setState({ items, status: 'ready', error: null });
|
|
||||||
return items;
|
|
||||||
} catch (error) {
|
|
||||||
automat.setState({ error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the cached list without a round-trip.
|
|
||||||
*
|
|
||||||
* @param {Array<T>} items Records to cache.
|
|
||||||
* @returns {Array<T>} Cached records.
|
|
||||||
*/
|
|
||||||
replace(items) {
|
|
||||||
automat.setState({ items, status: 'ready', error: null });
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-fetches the list once the selected company is known.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<T>>} Loaded records.
|
|
||||||
*/
|
|
||||||
ensureLoaded() {
|
|
||||||
if (automat.state.status === 'ready') {
|
|
||||||
return Promise.resolve(automat.state.items);
|
|
||||||
}
|
|
||||||
return actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Reset to idle state (called on logout). */
|
|
||||||
reset() {
|
|
||||||
inflightLoad = null;
|
|
||||||
automat.setState(initialState);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
automat = new Automat(initialState, actions, { name });
|
|
||||||
|
|
||||||
// Load on session/company establishment, reset on logout.
|
|
||||||
authAutomat.subscribe((auth) => {
|
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
|
||||||
if (auth.status === 'authed' && companyId) {
|
|
||||||
void actions.load().catch(() => {});
|
|
||||||
} else if (auth.status === 'anon') {
|
|
||||||
actions.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return automat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selects the record list of a list automat.
|
|
||||||
*
|
|
||||||
* @param {Automat<ListAutomatState>} automat List automat.
|
|
||||||
* @returns {Array<object>} Current records.
|
|
||||||
*/
|
|
||||||
export function selectListItems(automat) {
|
|
||||||
return automat.state.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selects the load status of a list automat.
|
|
||||||
*
|
|
||||||
* @param {Automat<ListAutomatState>} automat List automat.
|
|
||||||
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
|
||||||
*/
|
|
||||||
export function selectListStatus(automat) {
|
|
||||||
return automat.state.status;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file customergroupAutomat.js
|
|
||||||
* @description Manages cached customer groups (`GET /api/customergroup`) for
|
|
||||||
* the active company. Built via `createListAutomat`.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
|
||||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
|
||||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { customergroup as customergroupApi } from '../api';
|
|
||||||
import { createListAutomat } from './createListAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} CustomerGroup
|
|
||||||
* @property {string|number} id Customer group id.
|
|
||||||
* @property {string} name Group name.
|
|
||||||
* @property {number} sort Sort index.
|
|
||||||
* @property {boolean} editable False for reserved POS / Lieferanten groups.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Company customer groups (`GET /api/customergroup`).
|
|
||||||
*
|
|
||||||
* Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must
|
|
||||||
* not be renamed or deleted by the UI.
|
|
||||||
*
|
|
||||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
|
||||||
*/
|
|
||||||
export const customergroupAutomat = createListAutomat({
|
|
||||||
name: 'customergroup',
|
|
||||||
fetch: async () => {
|
|
||||||
const data = await customergroupApi.list();
|
|
||||||
return data.customergroups ?? [];
|
|
||||||
},
|
|
||||||
sort: (items) =>
|
|
||||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default customergroupAutomat;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { article as articleApi, supplier as supplierApi } from '../api';
|
import { article as articleApi, supplier as supplierApi } from '../api';
|
||||||
|
import { todayInputValue } from '../components/manifest/manifestConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} DeliveryLine
|
* @typedef {Object} DeliveryLine
|
||||||
@@ -62,6 +63,14 @@ import { article as articleApi, supplier as supplierApi } from '../api';
|
|||||||
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
|
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function parseNum(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.');
|
||||||
|
if (cleaned === '') return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a partial line into an editor line with a stable key.
|
* Normalizes a partial line into an editor line with a stable key.
|
||||||
*
|
*
|
||||||
@@ -69,14 +78,29 @@ import { article as articleApi, supplier as supplierApi } from '../api';
|
|||||||
* @returns {DeliveryLine} Normalized line.
|
* @returns {DeliveryLine} Normalized line.
|
||||||
*/
|
*/
|
||||||
export function createEmptyLineitem(line = {}) {
|
export function createEmptyLineitem(line = {}) {
|
||||||
const netVal = line.netprice ?? line.net ?? null;
|
let netVal = null;
|
||||||
const taxVal = line.taxrate ?? line.tax ?? null;
|
if (line.netprice != null || line.net != null || line.netPrice != null) {
|
||||||
|
const netRaw = line.netprice ?? line.net ?? line.netPrice;
|
||||||
|
netVal = netRaw != null ? (parseNum(netRaw) ?? netRaw) : null;
|
||||||
|
} else if (line.eknetto != null && line.eknetto !== '') {
|
||||||
|
const parsedCent = parseNum(line.eknetto);
|
||||||
|
netVal = parsedCent != null ? Math.round((parsedCent / 100) * 10000) / 10000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxRaw = line.taxrate ?? line.tax ?? line.mwst ?? line.taxRate ?? null;
|
||||||
|
const amountRaw = line.amount ?? line.count ?? line.qty ?? line.quantity ?? null;
|
||||||
|
const puRaw = line.pu ?? line.vpe ?? null;
|
||||||
|
|
||||||
|
const taxVal = taxRaw != null ? (parseNum(taxRaw) ?? taxRaw) : null;
|
||||||
|
const amountVal = amountRaw != null ? (parseNum(amountRaw) ?? amountRaw) : null;
|
||||||
|
const puVal = puRaw != null ? (parseNum(puRaw) ?? puRaw) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
name: line.name ?? null,
|
name: line.name ?? line.articleName ?? line.title ?? null,
|
||||||
sku: line.sku ?? null,
|
sku: line.sku ?? line.liefArtNr ?? line.articleNumber ?? null,
|
||||||
amount: line.amount ?? null,
|
amount: amountVal,
|
||||||
pu: line.pu ?? null,
|
pu: puVal,
|
||||||
unit: line.unit ?? null,
|
unit: line.unit ?? null,
|
||||||
netprice: netVal,
|
netprice: netVal,
|
||||||
net: netVal,
|
net: netVal,
|
||||||
@@ -132,7 +156,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
|||||||
export const initialDelivery = {
|
export const initialDelivery = {
|
||||||
id: null,
|
id: null,
|
||||||
deliveryId: null,
|
deliveryId: null,
|
||||||
date: null,
|
date: todayInputValue(),
|
||||||
deliveryEntity: null,
|
deliveryEntity: null,
|
||||||
partyId: null,
|
partyId: null,
|
||||||
externalNumber: null,
|
externalNumber: null,
|
||||||
@@ -203,12 +227,14 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
: delivery.notes ? [delivery.notes] : [];
|
: delivery.notes ? [delivery.notes] : [];
|
||||||
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
|
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
|
||||||
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
||||||
|
const date = delivery.date !== undefined ? delivery.date : (id != null ? null : (automat.state.date || todayInputValue()));
|
||||||
|
|
||||||
automat.setState({
|
automat.setState({
|
||||||
...initialDelivery,
|
...initialDelivery,
|
||||||
...delivery,
|
...delivery,
|
||||||
id,
|
id,
|
||||||
deliveryId: id,
|
deliveryId: id,
|
||||||
|
date,
|
||||||
partyId,
|
partyId,
|
||||||
deliveryEntity: partyId,
|
deliveryEntity: partyId,
|
||||||
externalNumber: extNum,
|
externalNumber: extNum,
|
||||||
@@ -375,6 +401,24 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
automat.actions.removeLineitem(payload);
|
automat.actions.removeLineitem(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a lineitem from one index to another.
|
||||||
|
* @param {number} fromIndex Source index.
|
||||||
|
* @param {number} toIndex Target index.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
moveLineitem(fromIndex, toIndex) {
|
||||||
|
const currentLines = automat.state.lines ?? [];
|
||||||
|
if (fromIndex < 0 || fromIndex >= currentLines.length || toIndex < 0 || toIndex >= currentLines.length) return;
|
||||||
|
const nextLines = [...currentLines];
|
||||||
|
const [item] = nextLines.splice(fromIndex, 1);
|
||||||
|
nextLines.splice(toIndex, 0, item);
|
||||||
|
automat.setState({
|
||||||
|
lines: nextLines,
|
||||||
|
lineitems: nextLines,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/** Clears all lines from the editor. @returns {void} */
|
/** Clears all lines from the editor. @returns {void} */
|
||||||
clearLineitems() {
|
clearLineitems() {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
@@ -423,7 +467,7 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await supplierApi.getArticles(entityId, skus);
|
const res = await supplierApi.getArticles(entityId);
|
||||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||||
|
|
||||||
const map = buildSupplierArticlesMap(articles);
|
const map = buildSupplierArticlesMap(articles);
|
||||||
@@ -629,6 +673,7 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
resetDelivery() {
|
resetDelivery() {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
...initialDelivery,
|
...initialDelivery,
|
||||||
|
date: todayInputValue(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -636,8 +681,154 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
resetDeliveryEditor() {
|
resetDeliveryEditor() {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
date: todayInputValue(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a delivery manifest (e.g. from draft_delivery_manifest tool) into the editor.
|
||||||
|
* @param {Object} manifestData
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
loadDraftManifest(manifestData) {
|
||||||
|
if (!manifestData || typeof manifestData !== 'object') return;
|
||||||
|
const data = manifestData.manifest ?? manifestData;
|
||||||
|
const basedata = data.basedata ?? data.baseData ?? data;
|
||||||
|
const rawItems =
|
||||||
|
data.items?.items ??
|
||||||
|
data.items ??
|
||||||
|
data.lines ??
|
||||||
|
data.lineitems ??
|
||||||
|
[];
|
||||||
|
const itemsList = Array.isArray(rawItems) ? rawItems : [];
|
||||||
|
|
||||||
|
const partyId =
|
||||||
|
basedata.lieferant_id ??
|
||||||
|
basedata.lieferantId ??
|
||||||
|
basedata.partyId ??
|
||||||
|
basedata.deliveryEntity ??
|
||||||
|
basedata.supplier_id ??
|
||||||
|
basedata.supplierId ??
|
||||||
|
null;
|
||||||
|
const supplierName =
|
||||||
|
basedata.lieferantName ??
|
||||||
|
basedata.supplierName ??
|
||||||
|
basedata.unmatchedSupplierName ??
|
||||||
|
null;
|
||||||
|
const extNum =
|
||||||
|
basedata.manifestNr ??
|
||||||
|
basedata.manifestNumber ??
|
||||||
|
basedata.externalNumber ??
|
||||||
|
basedata.external_number ??
|
||||||
|
basedata.invoiceNumber ??
|
||||||
|
basedata.invoice_number ??
|
||||||
|
null;
|
||||||
|
const date = basedata.date ?? automat.state.date ?? todayInputValue();
|
||||||
|
const type = basedata.manifestType ?? basedata.type ?? 'rechnung';
|
||||||
|
|
||||||
|
let matchedPartyId = partyId != null && partyId !== '' ? partyId : null;
|
||||||
|
if (!matchedPartyId && supplierName && automat.state.deliveryEntities?.length > 0) {
|
||||||
|
const normName = String(supplierName).trim().toLowerCase();
|
||||||
|
const found = automat.state.deliveryEntities.find(
|
||||||
|
(e) =>
|
||||||
|
(e.name && String(e.name).trim().toLowerCase() === normName) ||
|
||||||
|
(e.company && String(e.company).trim().toLowerCase() === normName)
|
||||||
|
);
|
||||||
|
if (found?.id != null) {
|
||||||
|
matchedPartyId = found.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = itemsList.map((item, idx) =>
|
||||||
|
createEmptyLineitem({
|
||||||
|
...item,
|
||||||
|
key: item.key ?? `manifest-${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preserve already attached files in the editor so loading a draft manifest does not wipe them out.
|
||||||
|
const currentAttachments =
|
||||||
|
automat.state.attachments?.length > 0
|
||||||
|
? automat.state.attachments
|
||||||
|
: (Array.isArray(automat.state.files) && automat.state.files.length > 0 ? automat.state.files : []);
|
||||||
|
|
||||||
|
const rawManifestAttachments =
|
||||||
|
data.attachments ??
|
||||||
|
data.files ??
|
||||||
|
basedata.attachments ??
|
||||||
|
basedata.files ??
|
||||||
|
null;
|
||||||
|
const manifestAttachments = Array.isArray(rawManifestAttachments) ? rawManifestAttachments : [];
|
||||||
|
|
||||||
|
let attachments = currentAttachments;
|
||||||
|
if (currentAttachments.length === 0) {
|
||||||
|
attachments = manifestAttachments;
|
||||||
|
} else if (manifestAttachments.length > 0) {
|
||||||
|
const existingKeys = new Set(
|
||||||
|
currentAttachments.flatMap((a) => [
|
||||||
|
a.id != null ? String(a.id) : null,
|
||||||
|
a.name ? String(a.name).trim().toLowerCase() : null,
|
||||||
|
]).filter(Boolean)
|
||||||
|
);
|
||||||
|
const newAttachments = manifestAttachments.filter((a) => {
|
||||||
|
const idKey = a.id != null ? String(a.id) : null;
|
||||||
|
const nameKey = a.name ? String(a.name).trim().toLowerCase() : null;
|
||||||
|
if (idKey && existingKeys.has(idKey)) return false;
|
||||||
|
if (nameKey && existingKeys.has(nameKey)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
attachments = [...currentAttachments, ...newAttachments];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.actions.setDelivery({
|
||||||
|
id: null,
|
||||||
|
deliveryId: null,
|
||||||
|
date,
|
||||||
|
partyId: matchedPartyId,
|
||||||
|
deliveryEntity: matchedPartyId,
|
||||||
|
unmatchedSupplierName: matchedPartyId != null ? null : supplierName,
|
||||||
|
externalNumber: extNum,
|
||||||
|
external_number: extNum,
|
||||||
|
type,
|
||||||
|
lines,
|
||||||
|
lineitems: lines,
|
||||||
|
attachments,
|
||||||
|
files: attachments,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Serializes the delivery into a VFS draft manifest format. @returns {object} */
|
||||||
|
getManifest() {
|
||||||
|
const state = automat.state;
|
||||||
|
const rawLines = state.lines ?? state.lineitems ?? [];
|
||||||
|
const lines = rawLines
|
||||||
|
.filter((l) => Boolean(l && (l.sku || l.name || l.amount != null || l.netprice != null || l.net != null)))
|
||||||
|
.map((l) => ({
|
||||||
|
sku: l.sku || null,
|
||||||
|
name: l.name || null,
|
||||||
|
amount: l.amount != null ? Number(l.amount) : null,
|
||||||
|
pu: l.pu != null ? Number(l.pu) : 1,
|
||||||
|
unit: l.unit || null,
|
||||||
|
netprice: l.netprice != null ? Number(l.netprice) : (l.net != null ? Number(l.net) : null),
|
||||||
|
taxrate: l.taxrate != null ? Number(l.taxrate) : (l.tax != null ? Number(l.tax) : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
kind: 'delivery',
|
||||||
|
id: null,
|
||||||
|
date: state.date || null,
|
||||||
|
partyId: state.partyId ?? state.deliveryEntity ?? null,
|
||||||
|
externalNumber: state.externalNumber ?? state.external_number ?? null,
|
||||||
|
lines,
|
||||||
|
items: lines,
|
||||||
|
notes: state.notes ?? [],
|
||||||
|
attachments: (state.attachments ?? []).map((a) => ({ name: a.name, type: a.type })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
automat = new Automat(initialState, actions, {
|
automat = new Automat(initialState, actions, {
|
||||||
@@ -675,6 +866,7 @@ export const {
|
|||||||
setId,
|
setId,
|
||||||
setDeliveryEntities,
|
setDeliveryEntities,
|
||||||
setDelivery,
|
setDelivery,
|
||||||
|
loadDraftManifest,
|
||||||
setDate,
|
setDate,
|
||||||
setDeliveryEntity,
|
setDeliveryEntity,
|
||||||
setPartyId,
|
setPartyId,
|
||||||
|
|||||||
@@ -1,42 +1,214 @@
|
|||||||
/**
|
/**
|
||||||
* @file entityAutomat.js
|
* @file entityAutomat.js
|
||||||
* @description Manages cached customer and contact entities (`GET /api/entity`)
|
* @description Manages cached customer and contact entities (`GET /api/entity`)
|
||||||
* for the active company. Built via `createListAutomat`.
|
* for the active company. Follows the brand/warehouse/customergroup automat pattern
|
||||||
|
* with filtering, deduplicated loads, silent reloads, and CRUD actions.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
* - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`,
|
||||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
* and `actions.reset()` on `'anon'`.
|
||||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
* - UI / Routes: Mutating actions (`create`, `update`, `remove`, `filter`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
import { entity as entityApi } from '../api';
|
import { entity as entityApi } from '../api';
|
||||||
import { createListAutomat } from './createListAutomat';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
import companyAutomat from './companyAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Entity
|
* @typedef {Object} Entity
|
||||||
* @property {string|number} id Entity id.
|
* @property {string|number} id Entity id.
|
||||||
* @property {string|number} customergroupId Owning customer group id.
|
* @property {string|number} customergroupId Owning customer group id.
|
||||||
* @property {string|null} firstName First name.
|
* @property {string} [number] Customer number.
|
||||||
|
* @property {string|null} [firstName] First name.
|
||||||
* @property {string} name Last / company name.
|
* @property {string} name Last / company name.
|
||||||
|
* @property {string} [createdAt] Created timestamp.
|
||||||
|
* @property {string} [updatedAt] Updated timestamp.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} EntityState
|
||||||
|
* @property {Array<Entity>} items Loaded records.
|
||||||
|
* @property {Array<Entity>} filteredItems Records matching active search filter.
|
||||||
|
* @property {string} filter Active search/filter string.
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||||
|
* @property {Error|string|null} error Last load error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {EntityState} */
|
||||||
|
const initialState = {
|
||||||
|
items: [],
|
||||||
|
filteredItems: [],
|
||||||
|
filter: '',
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilter = (entities, filter) => {
|
||||||
|
const query = String(filter ?? '').trim().toLowerCase();
|
||||||
|
if (!query) return entities;
|
||||||
|
const groups = companyAutomat?.state?.customergroups ?? [];
|
||||||
|
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
|
||||||
|
|
||||||
|
return entities.filter((e) => {
|
||||||
|
const name = String(e.name ?? '').toLowerCase();
|
||||||
|
const firstName = String(e.firstName ?? '').toLowerCase();
|
||||||
|
const number = String(e.number ?? '').toLowerCase();
|
||||||
|
const full1 = `${firstName} ${name}`.trim();
|
||||||
|
const full2 = `${name} ${firstName}`.trim();
|
||||||
|
const group = String(groupsById[e.customergroupId]?.name ?? '').toLowerCase();
|
||||||
|
return (
|
||||||
|
name.includes(query) ||
|
||||||
|
firstName.includes(query) ||
|
||||||
|
number.includes(query) ||
|
||||||
|
full1.includes(query) ||
|
||||||
|
full2.includes(query) ||
|
||||||
|
group.includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortEntities = (items) =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
const byName = String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de');
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
let entityAutomat;
|
||||||
|
|
||||||
|
const updateStateWithEntities = (entities, extra = {}) => {
|
||||||
|
const list = sortEntities(Array.isArray(entities) ? entities : []);
|
||||||
|
const filteredItems = applyFilter(list, entityAutomat.state.filter);
|
||||||
|
entityAutomat.setState({ items: list, filteredItems, ...extra });
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchEntities() {
|
||||||
|
const data = await entityApi.list();
|
||||||
|
requireObject(data, 'GET /api/entity');
|
||||||
|
return requireArray(data.entities, 'entities');
|
||||||
|
}
|
||||||
|
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
function loadEntities() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
entityAutomat.setState({ status: 'loading', error: null });
|
||||||
|
try {
|
||||||
|
const entities = await fetchEntities();
|
||||||
|
updateStateWithEntities(entities, { status: 'ready', error: null });
|
||||||
|
return entities;
|
||||||
|
} catch (error) {
|
||||||
|
entityAutomat.setState({ status: 'failed', error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/** Sets the active search filter query and updates filteredItems. */
|
||||||
|
filter(query) {
|
||||||
|
const filter = String(query ?? '');
|
||||||
|
entityAutomat.setState({
|
||||||
|
filter,
|
||||||
|
filteredItems: applyFilter(entityAutomat.state.items, filter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Loads the list, deduplicating concurrent callers. */
|
||||||
|
load() {
|
||||||
|
return loadEntities();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Silently refreshes the entities without flashing loading state. */
|
||||||
|
async reload() {
|
||||||
|
try {
|
||||||
|
const entities = await fetchEntities();
|
||||||
|
updateStateWithEntities(entities, { status: 'ready', error: null });
|
||||||
|
return entities;
|
||||||
|
} catch (error) {
|
||||||
|
entityAutomat.setState({ error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Replaces the cached list without an API call. */
|
||||||
|
replace(items) {
|
||||||
|
return updateStateWithEntities(items, { status: 'ready', error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns cached items if ready, otherwise loads. */
|
||||||
|
ensureLoaded() {
|
||||||
|
if (entityAutomat.state.status === 'ready') {
|
||||||
|
return Promise.resolve(entityAutomat.state.items);
|
||||||
|
}
|
||||||
|
return actions.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resets state to initial idle values. */
|
||||||
|
reset() {
|
||||||
|
inflightLoad = null;
|
||||||
|
entityAutomat.setState(initialState);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Creates a customer entity and refreshes the cache. */
|
||||||
|
async create(body) {
|
||||||
|
const data = await entityApi.create(body);
|
||||||
|
requireObject(data, 'POST /api/entity');
|
||||||
|
requireObject(data.entity, 'entity');
|
||||||
|
requireDefined(data.entity.id, 'entity.id');
|
||||||
|
await entityAutomat.actions.reload();
|
||||||
|
return data.entity;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates an entity name/fields and refreshes the cache. */
|
||||||
|
async update(id, body) {
|
||||||
|
const data = await entityApi.update(id, body);
|
||||||
|
requireObject(data, 'PATCH /api/entity/:id');
|
||||||
|
requireObject(data.entity, 'entity');
|
||||||
|
requireDefined(data.entity.id, 'entity.id');
|
||||||
|
await entityAutomat.actions.reload();
|
||||||
|
return data.entity;
|
||||||
|
},
|
||||||
|
|
||||||
|
rename(id, body) {
|
||||||
|
return actions.update(id, body);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deletes an entity and refreshes the cache. */
|
||||||
|
async remove(id) {
|
||||||
|
await entityApi.delete(id);
|
||||||
|
await entityAutomat.actions.reload();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company customers and contacts (`GET /api/entity`).
|
* Company customers and contacts (`GET /api/entity`).
|
||||||
*
|
*
|
||||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
* @type {Automat<EntityState>}
|
||||||
*/
|
*/
|
||||||
export const entityAutomat = createListAutomat({
|
entityAutomat = new Automat(initialState, actions, {
|
||||||
name: 'entity',
|
name: 'entity',
|
||||||
fetch: async () => {
|
|
||||||
const data = await entityApi.list();
|
|
||||||
return data.entities ?? [];
|
|
||||||
},
|
|
||||||
sort: (items) =>
|
|
||||||
[...items].sort((a, b) => {
|
|
||||||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
|
||||||
if (byName !== 0) return byName;
|
|
||||||
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { entityAutomat };
|
||||||
|
|
||||||
|
// Load on session/company establishment, reset on logout.
|
||||||
|
const syncEntityWithAuth = (auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
|
if (auth.status === 'authed' && companyId) {
|
||||||
|
void actions.load().catch(() => {});
|
||||||
|
} else if (auth.status === 'anon') {
|
||||||
|
actions.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
authAutomat.subscribe(syncEntityWithAuth);
|
||||||
|
syncEntityWithAuth(authAutomat.state);
|
||||||
|
|
||||||
export default entityAutomat;
|
export default entityAutomat;
|
||||||
|
|||||||
@@ -1,45 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file globalHistoryAutomat.js
|
* @file globalHistoryAutomat.js
|
||||||
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for
|
* @description Double-buffered chunk list automat (`globalHistoryAutomat`) for
|
||||||
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`.
|
* global system and change history logs with multi-filter caching and LRU eviction.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
* `selectedCompanyId` changes.
|
||||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||||
|
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
import { globalHistory } from '../api';
|
import { globalHistory } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
export const globalHistoryGridAutomat = createGridAutomat({
|
export const globalHistoryAutomat = createChunkListAutomat({
|
||||||
name: 'globalHistory',
|
name: 'globalHistory',
|
||||||
blockSize: 50,
|
chunkSize: 50,
|
||||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
maxLruChunks: 50,
|
||||||
try {
|
fetchPage: (params) => globalHistory.list(params),
|
||||||
const res = await globalHistory.list({
|
|
||||||
startRow,
|
|
||||||
endRow,
|
|
||||||
sortModel,
|
|
||||||
filterModel,
|
|
||||||
});
|
|
||||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
|
||||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
|
||||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
|
||||||
return { rows, lastRow };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to fetch global history (backend route pending):', err);
|
|
||||||
return { rows: [], lastRow: 0 };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload on company switch or auth change
|
// Alias for backwards compatibility
|
||||||
authAutomat.subscribe((auth) => {
|
export const globalHistoryGridAutomat = globalHistoryAutomat;
|
||||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
|
||||||
globalHistoryGridAutomat.actions.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default globalHistoryGridAutomat;
|
export default globalHistoryAutomat;
|
||||||
|
|||||||
100
src/automats/healthAutomat.js
Normal file
100
src/automats/healthAutomat.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @file healthAutomat.js
|
||||||
|
* @description Backend latency samples for the settings "Gesundheit" tab
|
||||||
|
* (`GET /api/health/latency`). Not company-scoped; only polls while a view
|
||||||
|
* has called `actions.startPolling()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { health as healthApi } from '../api';
|
||||||
|
|
||||||
|
const POLL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ timestamp: number, value: number }} LatencyPoint
|
||||||
|
* @typedef {Object} HealthState
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status
|
||||||
|
* @property {string|null} error
|
||||||
|
* @property {'3h'|'all'} range
|
||||||
|
* @property {LatencyPoint[]} raw Per-minute samples (last 3h)
|
||||||
|
* @property {LatencyPoint[]} avg 30-min rolling 4h average (range=all only)
|
||||||
|
* @property {LatencyPoint[]} max 30-min rolling 4h max (range=all only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {HealthState} */
|
||||||
|
const initialState = {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
range: '3h',
|
||||||
|
raw: [],
|
||||||
|
avg: [],
|
||||||
|
max: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let healthAutomat;
|
||||||
|
let inflight = null;
|
||||||
|
let timer = null;
|
||||||
|
let pollers = 0;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
// #region load
|
||||||
|
// MARK: calls GET /api/health/latency from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Loads latency data for the current range. Deduplicates concurrent calls.
|
||||||
|
* @param {boolean} [silent=false] Keep status 'ready' while refreshing
|
||||||
|
*/
|
||||||
|
load(silent = false) {
|
||||||
|
if (inflight) return inflight;
|
||||||
|
const { range, status } = healthAutomat.state;
|
||||||
|
if (!silent && status !== 'ready') healthAutomat.setState({ status: 'loading', error: null });
|
||||||
|
inflight = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await healthApi.getLatency(range);
|
||||||
|
// Range may have changed while the request was in flight.
|
||||||
|
if (healthAutomat.state.range !== range) return;
|
||||||
|
healthAutomat.setState({
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
raw: data?.raw ?? [],
|
||||||
|
avg: data?.avg ?? [],
|
||||||
|
max: data?.max ?? [],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
healthAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||||
|
} finally {
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflight;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/** Switches between the live 3h view and the long-term view. */
|
||||||
|
setRange(range) {
|
||||||
|
if (range === healthAutomat.state.range) return;
|
||||||
|
healthAutomat.setState({ range, status: 'loading', error: null });
|
||||||
|
void healthAutomat.actions.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
// #region polling
|
||||||
|
/** Starts the once-a-minute refresh (ref-counted). */
|
||||||
|
startPolling() {
|
||||||
|
pollers += 1;
|
||||||
|
if (pollers > 1) return;
|
||||||
|
void actions.load(healthAutomat.state.status === 'ready');
|
||||||
|
timer = setInterval(() => void actions.load(true), POLL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
pollers = Math.max(0, pollers - 1);
|
||||||
|
if (pollers === 0 && timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
|
|
||||||
|
healthAutomat = new Automat(initialState, actions, { name: 'health' });
|
||||||
|
|
||||||
|
export default healthAutomat;
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file index.js
|
* @file index.js
|
||||||
* @description Central barrel exporting all domain, configuration, and list
|
* @description Central barrel exporting domain, configuration, and list
|
||||||
* automats, along with their action creators and reactive selectors.
|
* automats, along with their action creators and reactive selectors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
|
export * from './manifestDraftAutomat';
|
||||||
|
|
||||||
export { default as authAutomat } from './authAutomat';
|
export { default as authAutomat } from './authAutomat';
|
||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
@@ -21,9 +22,6 @@ export {
|
|||||||
clearNavigation,
|
clearNavigation,
|
||||||
} from './navigationAutomat';
|
} from './navigationAutomat';
|
||||||
|
|
||||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
|
||||||
export { setViewerPosition } from './userSettingsAutomat';
|
|
||||||
|
|
||||||
export { default as helpAutomat } from './helpAutomat';
|
export { default as helpAutomat } from './helpAutomat';
|
||||||
export {
|
export {
|
||||||
toggleHelp,
|
toggleHelp,
|
||||||
@@ -43,9 +41,6 @@ export {
|
|||||||
applyPwaUpdate,
|
applyPwaUpdate,
|
||||||
} from './pwaAutomat';
|
} from './pwaAutomat';
|
||||||
|
|
||||||
export { default as supplierAutomat } from './supplierAutomat';
|
|
||||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
|
||||||
|
|
||||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||||
export {
|
export {
|
||||||
default as articleSearchAutomat,
|
default as articleSearchAutomat,
|
||||||
@@ -60,24 +55,72 @@ export {
|
|||||||
export {
|
export {
|
||||||
default as articleIndexAutomat,
|
default as articleIndexAutomat,
|
||||||
setArticleIndexName,
|
setArticleIndexName,
|
||||||
|
setArticleIndexStock,
|
||||||
|
updateArticleIndexRecord,
|
||||||
buildArticleIndex,
|
buildArticleIndex,
|
||||||
selectArticleIndexItems,
|
selectArticleIndexItems,
|
||||||
} from './articleIndexAutomat';
|
} from './articleIndexAutomat';
|
||||||
export { default as deliveryAutomat } from './deliveryAutomat';
|
export {
|
||||||
|
default as deliveryAutomat,
|
||||||
|
CHUNK_SIZE as DELIVERY_CHUNK_SIZE,
|
||||||
|
MAX_LRU_CHUNKS as DELIVERY_MAX_LRU_CHUNKS,
|
||||||
|
selectDeliveries,
|
||||||
|
selectDeliveryTotal,
|
||||||
|
selectDeliveryUnfilteredTotal,
|
||||||
|
selectDeliveryPagination,
|
||||||
|
selectOnlyUnbooked,
|
||||||
|
isDeliveryBooked,
|
||||||
|
getDeliveryUnbookedCount,
|
||||||
|
getChunkStats as getDeliveryChunkStats,
|
||||||
|
subscribeChunkStats as subscribeDeliveryChunkStats,
|
||||||
|
} from './deliveryAutomat';
|
||||||
export { default as quoteAutomat } from './quoteAutomat';
|
export { default as quoteAutomat } from './quoteAutomat';
|
||||||
export {
|
export {
|
||||||
default as orderAutomat,
|
default as orderAutomat,
|
||||||
selectOrders,
|
selectOrders,
|
||||||
selectOrderTotal,
|
selectOrderTotal,
|
||||||
|
selectOrderUnfilteredTotal,
|
||||||
selectOrderPagination,
|
selectOrderPagination,
|
||||||
|
selectOnlyUnpaid,
|
||||||
|
selectOnlyUndelivered,
|
||||||
|
selectNegativeSeekMonths,
|
||||||
|
isOrderPaid,
|
||||||
|
isOrderDelivered,
|
||||||
|
getNegativeSeekCutoff,
|
||||||
|
formatNegativeSeek,
|
||||||
|
getChunkStats,
|
||||||
|
subscribeChunkStats,
|
||||||
} from './orderAutomat';
|
} from './orderAutomat';
|
||||||
export { default as entityAutomat } from './entityAutomat';
|
export { default as entityAutomat } from './entityAutomat';
|
||||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
export {
|
||||||
export { default as numberAutomat } from './numberAutomat';
|
default as retoureAutomat,
|
||||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
selectRetoures,
|
||||||
|
selectRetoureTotal,
|
||||||
|
selectRetoureUnfilteredTotal,
|
||||||
|
isRetourePaid,
|
||||||
|
isRetoureShipped,
|
||||||
|
isRetoureDelivered,
|
||||||
|
getRetoureDate,
|
||||||
|
getItems as getRetoureItems,
|
||||||
|
requireChunk as requireRetoureChunk,
|
||||||
|
getChunkStats as getRetoureChunkStats,
|
||||||
|
subscribeChunkStats as subscribeRetoureChunkStats,
|
||||||
|
} from './retoureAutomat';
|
||||||
|
export { default as backupAutomat } from './backupAutomat';
|
||||||
|
export {
|
||||||
|
default as shipmentAutomat,
|
||||||
|
CHUNK_SIZE as SHIPMENT_CHUNK_SIZE,
|
||||||
|
selectShipments,
|
||||||
|
selectShipmentTotal,
|
||||||
|
selectShipmentUnfilteredTotal,
|
||||||
|
selectShipmentPagination,
|
||||||
|
selectStateFilter,
|
||||||
|
getChunkStats as getShipmentChunkStats,
|
||||||
|
subscribeChunkStats as subscribeShipmentChunkStats,
|
||||||
|
} from './shipmentAutomat';
|
||||||
|
|
||||||
/** Per-SKU article detail views keyed by SKU. */
|
/** Per-SKU article detail views keyed by SKU. */
|
||||||
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||||
|
|
||||||
/** Single shared GET /api/events connection. */
|
/** Single shared GET /api/events connection. */
|
||||||
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
|
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
|
||||||
@@ -92,27 +135,56 @@ export {
|
|||||||
|
|
||||||
export { default as companyAutomat } from './companyAutomat';
|
export { default as companyAutomat } from './companyAutomat';
|
||||||
export {
|
export {
|
||||||
|
selectCompanies,
|
||||||
selectCurrentCompany,
|
selectCurrentCompany,
|
||||||
selectCompanyId,
|
selectCompanyId,
|
||||||
selectCompanyStatus,
|
selectCompanyStatus,
|
||||||
selectCompanyName,
|
selectCompanyName,
|
||||||
selectCompanyIso639,
|
selectCompanyIso639,
|
||||||
|
selectCompanyLanguageId,
|
||||||
selectCompanyTaxrate,
|
selectCompanyTaxrate,
|
||||||
selectCompanyReducedTaxrate,
|
selectCompanyReducedTaxrate,
|
||||||
|
selectCompanyTaxfreerate,
|
||||||
selectCompanyRootCategoryId,
|
selectCompanyRootCategoryId,
|
||||||
} from './companyAutomat';
|
selectNumbers,
|
||||||
|
selectNumbersStatus,
|
||||||
export { default as brandAutomat } from './brandAutomat';
|
|
||||||
export { default as unitAutomat } from './unitAutomat';
|
|
||||||
export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat';
|
|
||||||
export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat';
|
|
||||||
|
|
||||||
export { default as posterminalAutomat } from './posterminalAutomat';
|
|
||||||
export {
|
|
||||||
selectPosterminals,
|
selectPosterminals,
|
||||||
selectPosterminalStatus,
|
selectPosterminalStatus,
|
||||||
selectHasPosterminals,
|
selectHasPosterminals,
|
||||||
} from './posterminalAutomat';
|
selectPrinterAgents,
|
||||||
|
selectPrinterAgentStatus,
|
||||||
|
selectHasPrinterAgents,
|
||||||
|
selectAgentPrinters,
|
||||||
|
selectAgentPrinterStatus,
|
||||||
|
selectLivePrinters,
|
||||||
|
selectSuppliers,
|
||||||
|
selectSuppliersStatus,
|
||||||
|
selectUnits,
|
||||||
|
selectUnitsStatus,
|
||||||
|
setViewerPosition,
|
||||||
|
setChatDrawerWidth,
|
||||||
|
selectChatDrawerWidth,
|
||||||
|
selectWarehouses,
|
||||||
|
selectWarehousesStatus,
|
||||||
|
selectProperties,
|
||||||
|
selectPropertyStatus,
|
||||||
|
selectCustomergroups,
|
||||||
|
selectCustomergroupsStatus,
|
||||||
|
selectCarriers,
|
||||||
|
selectCarriersStatus,
|
||||||
|
selectBrands,
|
||||||
|
selectBrandsStatus,
|
||||||
|
selectAttributes,
|
||||||
|
selectAttributeStatus,
|
||||||
|
selectAccounts,
|
||||||
|
selectAccountsStatus,
|
||||||
|
selectCompanyRole,
|
||||||
|
selectIsCompanyAdmin,
|
||||||
|
selectMembers,
|
||||||
|
selectMembersStatus,
|
||||||
|
selectInvites,
|
||||||
|
selectInvitesStatus,
|
||||||
|
} from './companyAutomat';
|
||||||
|
|
||||||
export { default as categoryAutomat } from './categoryAutomat';
|
export { default as categoryAutomat } from './categoryAutomat';
|
||||||
export {
|
export {
|
||||||
@@ -126,6 +198,7 @@ export {
|
|||||||
normalizeCategoryMap,
|
normalizeCategoryMap,
|
||||||
getCategoryState,
|
getCategoryState,
|
||||||
whenCategoryTreeReady,
|
whenCategoryTreeReady,
|
||||||
|
selectCategoryTreeArticleCount,
|
||||||
} from './categoryAutomat';
|
} from './categoryAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +220,8 @@ export {
|
|||||||
} from './categoryViewAutomat';
|
} from './categoryViewAutomat';
|
||||||
|
|
||||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
|
||||||
|
export { default as ledgerHistoryAutomat, ledgerHistoryGridAutomat } from './ledgerHistoryAutomat';
|
||||||
|
|
||||||
export { default as chatAutomat } from './chatAutomat';
|
export { default as chatAutomat } from './chatAutomat';
|
||||||
export {
|
export {
|
||||||
@@ -163,83 +237,14 @@ export {
|
|||||||
|
|
||||||
export { default as vfsAutomat } from './vfsAutomat';
|
export { default as vfsAutomat } from './vfsAutomat';
|
||||||
|
|
||||||
|
export { default as pinnedAppletAutomat } from './pinnedAppletAutomat';
|
||||||
/**
|
export {
|
||||||
* ============================================================================
|
isPinned,
|
||||||
* Overview of Automats in Dashfront
|
getPinnedForRoute,
|
||||||
* ============================================================================
|
pinApplet,
|
||||||
*
|
unpinApplet,
|
||||||
* Automats are lightweight, reactive, out-of-tree state containers powered by
|
togglePin,
|
||||||
* `react-automat`. They manage all client-side state without React context,
|
executeApplet,
|
||||||
* hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent`
|
closeExecution,
|
||||||
* via constructor reads and fine-grained subscriptions.
|
normalizeRoute,
|
||||||
*
|
} from './pinnedAppletAutomat';
|
||||||
* 1. Multi-Tenant Session & Security
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`,
|
|
||||||
* `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`).
|
|
||||||
* Coordinates session bootstrap, company switching, and acts as the root
|
|
||||||
* lifecycle trigger that loads or resets all company-scoped automats.
|
|
||||||
*
|
|
||||||
* 2. Real-Time SSE Hub
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `sseAutomat`: Manages the single persistent Server-Sent Events connection
|
|
||||||
* (`GET /api/events`). Automatically reconnects when the active company changes
|
|
||||||
* and terminates on logout. Dispatches incoming server mutation signals into
|
|
||||||
* targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`,
|
|
||||||
* `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`,
|
|
||||||
* `propertyAutomat`).
|
|
||||||
*
|
|
||||||
* 3. Domain & Entity Caches
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`)
|
|
||||||
* with inflight request deduplication, optimistic renames, and reactive selectors.
|
|
||||||
* - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`)
|
|
||||||
* providing per-SKU supplier catalog bindings and change history.
|
|
||||||
* - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts
|
|
||||||
* and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR).
|
|
||||||
*
|
|
||||||
* 4. Company Configuration & Catalogs
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID).
|
|
||||||
* - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create,
|
|
||||||
* rename, move, reorder, delete), and breadcrumb resolution.
|
|
||||||
* - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child
|
|
||||||
* categories per slug path, auto-preloaded when category tree finishes loading.
|
|
||||||
* - `brandAutomat`: Catalog manufacturer and brand records.
|
|
||||||
* - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.).
|
|
||||||
* - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters.
|
|
||||||
* - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color).
|
|
||||||
* - `propertyAutomat`: Custom product property and metadata names.
|
|
||||||
* - `supplierAutomat`: Directory of vendor entities and supplier article mappings.
|
|
||||||
* - `warehouseAutomat`: Warehouse locations and stock storage areas.
|
|
||||||
*
|
|
||||||
* 5. Company-Scoped List Automats (`createListAutomat`)
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
|
|
||||||
* and automatic synchronization with `authAutomat`:
|
|
||||||
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
|
|
||||||
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
|
|
||||||
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
|
|
||||||
* - `entityAutomat`: Customers and business partners (`GET /api/entity`).
|
|
||||||
* - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`).
|
|
||||||
* - `numberAutomat`: Document numbering sequences (`GET /api/number`).
|
|
||||||
*
|
|
||||||
* 6. Search & Infinite-Scroll Grid Automats
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
|
|
||||||
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
|
|
||||||
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
|
|
||||||
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
|
|
||||||
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
|
|
||||||
*
|
|
||||||
* 7. UI State & Utilities
|
|
||||||
* ----------------------------------------------------------------------------
|
|
||||||
* - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens.
|
|
||||||
* - `navigationAutomat`: Persists last visited delivery document path and article SKU.
|
|
||||||
* - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB).
|
|
||||||
* - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB).
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
src/automats/ledgerHistoryAutomat.js
Normal file
26
src/automats/ledgerHistoryAutomat.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file ledgerHistoryAutomat.js
|
||||||
|
* @description Double-buffered chunk list automat (`ledgerHistoryAutomat`) for
|
||||||
|
* append-only ledger transactions and stock journal with multi-filter caching and LRU eviction.
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||||
|
* `selectedCompanyId` changes.
|
||||||
|
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||||
|
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
|
import { ledger } from '../api';
|
||||||
|
|
||||||
|
export const ledgerHistoryAutomat = createChunkListAutomat({
|
||||||
|
name: 'ledgerHistory',
|
||||||
|
chunkSize: 50,
|
||||||
|
maxLruChunks: 50,
|
||||||
|
fetchPage: (params) => ledger.getHistory(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for consistency with other grid/chunk automats
|
||||||
|
export const ledgerHistoryGridAutomat = ledgerHistoryAutomat;
|
||||||
|
|
||||||
|
export default ledgerHistoryAutomat;
|
||||||
631
src/automats/manifestDraftAutomat.js
Normal file
631
src/automats/manifestDraftAutomat.js
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
/**
|
||||||
|
* @file manifestDraftAutomat.js
|
||||||
|
* @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat).
|
||||||
|
* Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { selectCompanyTaxrate } from './companyAutomat';
|
||||||
|
import artikelAutomat, { getArticle } from './artikelAutomat';
|
||||||
|
import {
|
||||||
|
emptyLine,
|
||||||
|
lineFromApi,
|
||||||
|
dateInputValue,
|
||||||
|
todayInputValue,
|
||||||
|
parseNum,
|
||||||
|
} from '../components/manifest/manifestConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the initial empty draft state for a manifest kind.
|
||||||
|
*
|
||||||
|
* @param {'quote'|'order'} kind
|
||||||
|
* @returns {object} Initial state
|
||||||
|
*/
|
||||||
|
export function createInitialManifestDraftState(kind) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
mode: 'create',
|
||||||
|
id: null,
|
||||||
|
date: todayInputValue(),
|
||||||
|
partyId: null,
|
||||||
|
number: '',
|
||||||
|
externalNumber: '',
|
||||||
|
lines: kind === 'order' ? [] : [emptyLine()],
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Automat instance managing draft state for a manifest kind.
|
||||||
|
*
|
||||||
|
* @param {{ kind: 'quote'|'order', name: string }} options
|
||||||
|
* @returns {Automat}
|
||||||
|
*/
|
||||||
|
export function createManifestDraftAutomat({ kind, name }) {
|
||||||
|
let automat;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/** @param {string} date */
|
||||||
|
setDate(date) {
|
||||||
|
automat.setState({ date: dateInputValue(date) });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {number|string|null} partyId */
|
||||||
|
setPartyId(partyId) {
|
||||||
|
automat.setState({ partyId: partyId != null ? partyId : null });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} externalNumber */
|
||||||
|
setExternalNumber(externalNumber) {
|
||||||
|
automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} number */
|
||||||
|
setNumber(number) {
|
||||||
|
automat.setState({ number: number != null ? String(number) : '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Array<object>} lines */
|
||||||
|
setLines(lines) {
|
||||||
|
const list = Array.isArray(lines) ? lines : [];
|
||||||
|
automat.setState({ lines: list });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing line by key.
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string|object} patchOrField
|
||||||
|
* @param {*} [value]
|
||||||
|
*/
|
||||||
|
updateLine(key, patchOrField, value) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const next = current.map((line) => {
|
||||||
|
if (line.key !== key) return line;
|
||||||
|
if (typeof patchOrField === 'object' && patchOrField != null) {
|
||||||
|
return { ...line, ...patchOrField };
|
||||||
|
}
|
||||||
|
return { ...line, [patchOrField]: value };
|
||||||
|
});
|
||||||
|
automat.setState({ lines: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {object} [line] */
|
||||||
|
addLine(line = null) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const newLine = line ? { ...emptyLine(), ...line } : emptyLine();
|
||||||
|
automat.setState({ lines: [...current, newLine] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} key */
|
||||||
|
removeLine(key) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const next = current.filter((l) => l.key !== key);
|
||||||
|
automat.setState({ lines: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAllLines() {
|
||||||
|
automat.setState({ lines: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Array<object>} attachments */
|
||||||
|
setAttachments(attachments) {
|
||||||
|
automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resets draft state back to clean initial state. */
|
||||||
|
reset() {
|
||||||
|
automat.setState(createInitialManifestDraftState(kind));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies full manifest update (from external agent, VFS, or SSE).
|
||||||
|
* @param {object} content
|
||||||
|
*/
|
||||||
|
setManifest(content) {
|
||||||
|
if (!content) return;
|
||||||
|
const manifest = content.manifest || content;
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
if (manifest.date !== undefined) {
|
||||||
|
updates.date = dateInputValue(manifest.date);
|
||||||
|
}
|
||||||
|
const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId;
|
||||||
|
if (party !== undefined) {
|
||||||
|
updates.partyId = party != null ? party : null;
|
||||||
|
}
|
||||||
|
const ext = manifest.externalNumber ?? manifest.external_number;
|
||||||
|
if (ext !== undefined) {
|
||||||
|
updates.externalNumber = ext != null ? String(ext) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems;
|
||||||
|
if (Array.isArray(rawLines)) {
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
|
const lines = rawLines.map((row) => {
|
||||||
|
const mapped = lineFromApi(row);
|
||||||
|
if (!mapped.amount) mapped.amount = '1';
|
||||||
|
if (!mapped.tax) {
|
||||||
|
mapped.tax = normTax;
|
||||||
|
mapped.taxrate = normTax;
|
||||||
|
}
|
||||||
|
if (!mapped.rebate) mapped.rebate = '0';
|
||||||
|
if (!mapped.name && mapped.sku) {
|
||||||
|
const art = getArticle(mapped.sku);
|
||||||
|
if (art?.name) mapped.name = art.name;
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
updates.lines = lines.length > 0 ? lines : [emptyLine()];
|
||||||
|
|
||||||
|
for (const l of updates.lines) {
|
||||||
|
if (l.sku) {
|
||||||
|
void artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState(updates);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes current draft state into VFS draft manifest format.
|
||||||
|
* @returns {object} Manifest payload
|
||||||
|
*/
|
||||||
|
getManifest() {
|
||||||
|
const state = automat.state;
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
|
|
||||||
|
const formattedLines = (state.lines || [])
|
||||||
|
.filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross)))
|
||||||
|
.map((l) => ({
|
||||||
|
sku: l.sku || null,
|
||||||
|
name: l.name || null,
|
||||||
|
amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1),
|
||||||
|
net: parseNum(l.net) ?? parseNum(l.netprice) ?? null,
|
||||||
|
gross: parseNum(l.gross) ?? null,
|
||||||
|
taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax),
|
||||||
|
rebate: parseNum(l.rebate) ?? 0,
|
||||||
|
pu: parseNum(l.pu) ?? 1,
|
||||||
|
unit: l.unit || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
kind,
|
||||||
|
mode: 'create',
|
||||||
|
id: null,
|
||||||
|
date: state.date || null,
|
||||||
|
partyId: state.partyId ?? null,
|
||||||
|
externalNumber: state.externalNumber || null,
|
||||||
|
lines: formattedLines,
|
||||||
|
items: formattedLines,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(createInitialManifestDraftState(kind), actions, {
|
||||||
|
name,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared singleton automat for quote draft. */
|
||||||
|
export const quoteDraftAutomat = createManifestDraftAutomat({
|
||||||
|
kind: 'quote',
|
||||||
|
name: 'quote-draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared singleton automat for order draft. */
|
||||||
|
export const orderDraftAutomat = createManifestDraftAutomat({
|
||||||
|
kind: 'order',
|
||||||
|
name: 'order-draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared singleton automat for retoure draft. */
|
||||||
|
export const retoureDraftAutomat = createManifestDraftAutomat({
|
||||||
|
kind: 'retoure',
|
||||||
|
name: 'retoure-draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the draft automat for a given manifest kind.
|
||||||
|
* @param {'quote'|'order'|'retoure'} kind
|
||||||
|
* @returns {Automat|null}
|
||||||
|
*/
|
||||||
|
export function getManifestDraftAutomat(kind) {
|
||||||
|
if (kind === 'quote') return quoteDraftAutomat;
|
||||||
|
if (kind === 'order') return orderDraftAutomat;
|
||||||
|
if (kind === 'retoure') return retoureDraftAutomat;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes detailed diff between current manifest draft/edit state and baseline snapshot.
|
||||||
|
*
|
||||||
|
* @param {object} current
|
||||||
|
* @param {object|null} baseline
|
||||||
|
* @returns {{ isDirty: boolean, fields: { date: boolean, partyId: boolean, externalNumber: boolean, number: boolean, lines: boolean, attachments: boolean }, dirtyLineKeys: string[], summary: string[] }}
|
||||||
|
*/
|
||||||
|
export function getManifestDirtyDetails(current, baseline) {
|
||||||
|
if (!baseline) {
|
||||||
|
return {
|
||||||
|
isDirty: false,
|
||||||
|
fields: {
|
||||||
|
date: false,
|
||||||
|
partyId: false,
|
||||||
|
externalNumber: false,
|
||||||
|
number: false,
|
||||||
|
lines: false,
|
||||||
|
attachments: false,
|
||||||
|
},
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
summary: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
date: (current.date || '') !== (baseline.date || ''),
|
||||||
|
partyId:
|
||||||
|
(current.partyId != null && current.partyId !== '' ? Number(current.partyId) : null) !==
|
||||||
|
(baseline.partyId != null && baseline.partyId !== '' ? Number(baseline.partyId) : null),
|
||||||
|
externalNumber:
|
||||||
|
String(current.externalNumber || '').trim() !== String(baseline.externalNumber || '').trim(),
|
||||||
|
number: String(current.number || '').trim() !== String(baseline.number || '').trim(),
|
||||||
|
lines: false,
|
||||||
|
attachments: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirtyLineKeys = [];
|
||||||
|
const dirtyLineFields = {};
|
||||||
|
const curLines = current.lines || [];
|
||||||
|
const baseLines = baseline.lines || [];
|
||||||
|
|
||||||
|
const baseLinesByKey = new Map();
|
||||||
|
for (let i = 0; i < baseLines.length; i++) {
|
||||||
|
const bl = baseLines[i];
|
||||||
|
if (bl.key) baseLinesByKey.set(bl.key, bl);
|
||||||
|
if (bl.id != null) baseLinesByKey.set(`id-${bl.id}`, bl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curLines.length !== baseLines.length) {
|
||||||
|
fields.lines = true;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < curLines.length; i++) {
|
||||||
|
const cl = curLines[i];
|
||||||
|
const bl = baseLines[i];
|
||||||
|
const clIdentifier = cl?.key ?? (cl?.id != null ? `id-${cl.id}` : null);
|
||||||
|
const blIdentifier = bl?.key ?? (bl?.id != null ? `id-${bl.id}` : null);
|
||||||
|
if (clIdentifier && blIdentifier && clIdentifier !== blIdentifier) {
|
||||||
|
fields.lines = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < curLines.length; i++) {
|
||||||
|
const cl = curLines[i];
|
||||||
|
const key = cl.key ?? `idx-${i}`;
|
||||||
|
const bl =
|
||||||
|
(cl.key && baseLinesByKey.get(cl.key)) ||
|
||||||
|
(cl.id != null && baseLinesByKey.get(`id-${cl.id}`)) ||
|
||||||
|
(baseLinesByKey.size === 0 ? baseLines[i] : null);
|
||||||
|
|
||||||
|
if (!bl) {
|
||||||
|
dirtyLineKeys.push(key);
|
||||||
|
dirtyLineFields[key] = { __all__: true };
|
||||||
|
fields.lines = true;
|
||||||
|
} else {
|
||||||
|
const lineFieldDiffs = {};
|
||||||
|
if (String(cl.sku ?? '').trim() !== String(bl.sku ?? '').trim()) {
|
||||||
|
lineFieldDiffs.sku = true;
|
||||||
|
}
|
||||||
|
if (String(cl.name ?? '').trim() !== String(bl.name ?? '').trim()) {
|
||||||
|
lineFieldDiffs.name = true;
|
||||||
|
}
|
||||||
|
if (String(cl.amount ?? cl.count ?? '').trim() !== String(bl.amount ?? bl.count ?? '').trim()) {
|
||||||
|
lineFieldDiffs.amount = true;
|
||||||
|
lineFieldDiffs.count = true;
|
||||||
|
}
|
||||||
|
if (String(cl.net ?? cl.netprice ?? '').trim() !== String(bl.net ?? bl.netprice ?? '').trim()) {
|
||||||
|
lineFieldDiffs.net = true;
|
||||||
|
lineFieldDiffs.netprice = true;
|
||||||
|
}
|
||||||
|
if (String(cl.gross ?? '').trim() !== String(bl.gross ?? '').trim()) {
|
||||||
|
lineFieldDiffs.gross = true;
|
||||||
|
}
|
||||||
|
if (String(cl.rebate ?? '').trim() !== String(bl.rebate ?? '').trim()) {
|
||||||
|
lineFieldDiffs.rebate = true;
|
||||||
|
}
|
||||||
|
if (String(cl.tax ?? cl.taxrate ?? '').trim() !== String(bl.tax ?? bl.taxrate ?? '').trim()) {
|
||||||
|
lineFieldDiffs.tax = true;
|
||||||
|
lineFieldDiffs.taxrate = true;
|
||||||
|
}
|
||||||
|
if (String(cl.pu ?? cl.vpe ?? '').trim() !== String(bl.pu ?? bl.vpe ?? '').trim()) {
|
||||||
|
lineFieldDiffs.pu = true;
|
||||||
|
lineFieldDiffs.vpe = true;
|
||||||
|
}
|
||||||
|
if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) {
|
||||||
|
lineFieldDiffs.unit = true;
|
||||||
|
}
|
||||||
|
if (Boolean(cl.shippable ?? true) !== Boolean(bl.shippable ?? true)) {
|
||||||
|
lineFieldDiffs.shippable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(lineFieldDiffs).length > 0) {
|
||||||
|
dirtyLineKeys.push(key);
|
||||||
|
dirtyLineFields[key] = lineFieldDiffs;
|
||||||
|
fields.lines = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check attachments
|
||||||
|
const curAtts = current.attachments || [];
|
||||||
|
const baseAtts = baseline.attachments || [];
|
||||||
|
if (curAtts.length !== baseAtts.length) {
|
||||||
|
fields.attachments = true;
|
||||||
|
} else {
|
||||||
|
for (const a of curAtts) {
|
||||||
|
if (!a.isPersisted || a.isDirty) {
|
||||||
|
fields.attachments = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = [];
|
||||||
|
if (fields.date) summary.push('Datum');
|
||||||
|
if (fields.partyId) summary.push(current.kind === 'delivery' ? 'Lieferant' : 'Kunde');
|
||||||
|
if (fields.externalNumber) summary.push('Referenz');
|
||||||
|
if (fields.number) summary.push('Nummer');
|
||||||
|
if (fields.lines) {
|
||||||
|
const count = dirtyLineKeys.length;
|
||||||
|
summary.push(count > 0 ? `${count} Artikelzeile${count === 1 ? '' : 'n'}` : 'Artikelzeilen');
|
||||||
|
}
|
||||||
|
if (fields.attachments && current.kind !== 'quote') summary.push('Dateien');
|
||||||
|
|
||||||
|
const isDirty = summary.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDirty,
|
||||||
|
fields,
|
||||||
|
dirtyLineKeys,
|
||||||
|
dirtyLineFields,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares current manifest draft/edit state against a baseline snapshot.
|
||||||
|
*
|
||||||
|
* @param {object} current
|
||||||
|
* @param {object|null} baseline
|
||||||
|
* @returns {boolean} True if any field or line has changed from baseline
|
||||||
|
*/
|
||||||
|
export function checkManifestDirty(current, baseline) {
|
||||||
|
return getManifestDirtyDetails(current, baseline).isDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Automat managing the single last-edited manifest for that kind.
|
||||||
|
*
|
||||||
|
* @param {{ kind: 'quote'|'order', name: string }} options
|
||||||
|
* @returns {Automat}
|
||||||
|
*/
|
||||||
|
export function createManifestEditAutomat({ kind, name }) {
|
||||||
|
let automat;
|
||||||
|
|
||||||
|
const emptyFields = {
|
||||||
|
date: false,
|
||||||
|
partyId: false,
|
||||||
|
externalNumber: false,
|
||||||
|
number: false,
|
||||||
|
lines: false,
|
||||||
|
attachments: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
loadDoc(id, doc) {
|
||||||
|
const date = dateInputValue(doc.date);
|
||||||
|
const createdAt = doc.createdAt ?? doc.created_at ?? null;
|
||||||
|
const partyId = doc.partyId ?? null;
|
||||||
|
const number = doc.number ?? '';
|
||||||
|
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
|
||||||
|
const lines = Array.isArray(doc.lines) ? doc.lines : [];
|
||||||
|
const attachments = Array.isArray(doc.attachments) ? doc.attachments : [];
|
||||||
|
|
||||||
|
const baseline = {
|
||||||
|
date,
|
||||||
|
createdAt,
|
||||||
|
partyId,
|
||||||
|
number,
|
||||||
|
externalNumber,
|
||||||
|
lines: lines.map((l) => ({ ...l })),
|
||||||
|
attachments: [...attachments],
|
||||||
|
};
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
kind,
|
||||||
|
mode: 'edit',
|
||||||
|
id,
|
||||||
|
date,
|
||||||
|
createdAt,
|
||||||
|
partyId,
|
||||||
|
number,
|
||||||
|
externalNumber,
|
||||||
|
lines,
|
||||||
|
attachments,
|
||||||
|
shipments: doc.shipments || [],
|
||||||
|
payments: doc.payments || [],
|
||||||
|
shippings: doc.shippings || [],
|
||||||
|
orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null,
|
||||||
|
paidTotal: doc.paidTotal ?? doc.paid_total ?? null,
|
||||||
|
paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
|
||||||
|
shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
|
||||||
|
delivered: Boolean(doc.delivered),
|
||||||
|
baseline,
|
||||||
|
isDirty: false,
|
||||||
|
dirtyFields: { ...emptyFields },
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
dirtyLineFields: {},
|
||||||
|
dirtySummary: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update(updates) {
|
||||||
|
const next = { ...automat.state, ...updates };
|
||||||
|
const dirtyInfo = getManifestDirtyDetails(next, automat.state.baseline);
|
||||||
|
automat.setState({
|
||||||
|
...updates,
|
||||||
|
isDirty: dirtyInfo.isDirty,
|
||||||
|
dirtyFields: dirtyInfo.fields,
|
||||||
|
dirtyLineKeys: dirtyInfo.dirtyLineKeys,
|
||||||
|
dirtyLineFields: dirtyInfo.dirtyLineFields,
|
||||||
|
dirtySummary: dirtyInfo.summary,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setAttachments(attachments) {
|
||||||
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
|
const base = automat.state.baseline;
|
||||||
|
const nextBaseline = base ? { ...base, attachments: [...list] } : null;
|
||||||
|
automat.setState({
|
||||||
|
attachments: list,
|
||||||
|
baseline: nextBaseline,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreBaseline() {
|
||||||
|
if (!automat.state.baseline) return;
|
||||||
|
const base = automat.state.baseline;
|
||||||
|
automat.setState({
|
||||||
|
date: base.date,
|
||||||
|
partyId: base.partyId,
|
||||||
|
number: base.number,
|
||||||
|
externalNumber: base.externalNumber,
|
||||||
|
lines: base.lines.map((l) => ({ ...l })),
|
||||||
|
attachments: [...base.attachments],
|
||||||
|
isDirty: false,
|
||||||
|
dirtyFields: { ...emptyFields },
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
dirtyLineFields: {},
|
||||||
|
dirtySummary: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
commitSaved(savedDoc) {
|
||||||
|
const date = dateInputValue(savedDoc.date);
|
||||||
|
const createdAt = savedDoc.createdAt ?? savedDoc.created_at ?? automat.state.createdAt ?? null;
|
||||||
|
const partyId = savedDoc.partyId ?? null;
|
||||||
|
const number = savedDoc.number ?? automat.state.number;
|
||||||
|
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
|
||||||
|
const lines = Array.isArray(savedDoc.lines) ? savedDoc.lines : [];
|
||||||
|
const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments;
|
||||||
|
|
||||||
|
const baseline = {
|
||||||
|
date,
|
||||||
|
createdAt,
|
||||||
|
partyId,
|
||||||
|
number,
|
||||||
|
externalNumber,
|
||||||
|
lines: lines.map((l) => ({ ...l })),
|
||||||
|
attachments: [...attachments],
|
||||||
|
};
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
date,
|
||||||
|
createdAt,
|
||||||
|
partyId,
|
||||||
|
number,
|
||||||
|
externalNumber,
|
||||||
|
lines,
|
||||||
|
attachments,
|
||||||
|
baseline,
|
||||||
|
isDirty: false,
|
||||||
|
dirtyFields: { ...emptyFields },
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
dirtyLineFields: {},
|
||||||
|
dirtySummary: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
automat.setState({
|
||||||
|
kind,
|
||||||
|
mode: 'edit',
|
||||||
|
id: null,
|
||||||
|
date: '',
|
||||||
|
createdAt: null,
|
||||||
|
partyId: null,
|
||||||
|
number: '',
|
||||||
|
externalNumber: '',
|
||||||
|
lines: [emptyLine()],
|
||||||
|
attachments: [],
|
||||||
|
baseline: null,
|
||||||
|
isDirty: false,
|
||||||
|
dirtyFields: { ...emptyFields },
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
dirtyLineFields: {},
|
||||||
|
dirtySummary: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
mode: 'edit',
|
||||||
|
id: null,
|
||||||
|
date: '',
|
||||||
|
createdAt: null,
|
||||||
|
partyId: null,
|
||||||
|
number: '',
|
||||||
|
externalNumber: '',
|
||||||
|
lines: [emptyLine()],
|
||||||
|
attachments: [],
|
||||||
|
baseline: null,
|
||||||
|
isDirty: false,
|
||||||
|
dirtyFields: { ...emptyFields },
|
||||||
|
dirtyLineKeys: [],
|
||||||
|
dirtyLineFields: {},
|
||||||
|
dirtySummary: [],
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
{ name, persist: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared singleton automat for the single last edited quote. */
|
||||||
|
export const quoteEditAutomat = createManifestEditAutomat({
|
||||||
|
kind: 'quote',
|
||||||
|
name: 'quote-edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared singleton automat for the single last edited order. */
|
||||||
|
export const orderEditAutomat = createManifestEditAutomat({
|
||||||
|
kind: 'order',
|
||||||
|
name: 'order-edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared singleton automat for the single last edited retoure. */
|
||||||
|
export const retoureEditAutomat = createManifestEditAutomat({
|
||||||
|
kind: 'retoure',
|
||||||
|
name: 'retoure-edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the edit automat for a given manifest kind.
|
||||||
|
* @param {'quote'|'order'|'retoure'} kind
|
||||||
|
* @returns {Automat|null}
|
||||||
|
*/
|
||||||
|
export function getManifestEditAutomat(kind) {
|
||||||
|
if (kind === 'quote') return quoteEditAutomat;
|
||||||
|
if (kind === 'order') return orderEditAutomat;
|
||||||
|
if (kind === 'retoure') return retoureEditAutomat;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file numberAutomat.js
|
|
||||||
* @description Manages cached document numbering sequences (`GET /api/number`)
|
|
||||||
* (e.g. delivery, entity) for the active company. Built via `createListAutomat`.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
|
||||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
|
||||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { number as numberApi } from '../api';
|
|
||||||
import { createListAutomat } from './createListAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} NumberSequence
|
|
||||||
* @property {string|number} id Sequence id.
|
|
||||||
* @property {string} type Document type key (e.g. `delivery`, `entity`).
|
|
||||||
* @property {string} prefix Prefix rendered before the counter.
|
|
||||||
* @property {number} padding Zero-padding width.
|
|
||||||
* @property {number} next Next counter value.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Company document number sequences (`GET /api/number`).
|
|
||||||
*
|
|
||||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
|
||||||
*/
|
|
||||||
export const numberAutomat = createListAutomat({
|
|
||||||
name: 'number',
|
|
||||||
fetch: async () => {
|
|
||||||
const data = await numberApi.list();
|
|
||||||
return data.numbers ?? [];
|
|
||||||
},
|
|
||||||
sort: (items) =>
|
|
||||||
[...items].sort((a, b) =>
|
|
||||||
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default numberAutomat;
|
|
||||||
File diff suppressed because it is too large
Load Diff
304
src/automats/pinnedAppletAutomat.js
Normal file
304
src/automats/pinnedAppletAutomat.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* @file pinnedAppletAutomat.js
|
||||||
|
* @description State container for applets pinned to browser routes.
|
||||||
|
* Persists pin assignments in company settings using the 'applet.pinned' namespace,
|
||||||
|
* with local storage fallback per company.
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth changes — loads pinned applet settings when authed
|
||||||
|
* with a selectedCompanyId, and resets when anon/logged out.
|
||||||
|
* - UI / VfsAppletRunner: Calls `pinApplet`, `unpinApplet`, `togglePin`.
|
||||||
|
* - UI / PinnedAppletFabs: Calls `executeApplet`, `closeExecution`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { setting as settingApi } from '../api';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a browser route pathname (strips trailing slashes, hashes, query params).
|
||||||
|
* @param {string} rawRoute
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizeRoute(rawRoute) {
|
||||||
|
if (!rawRoute) return '/';
|
||||||
|
const clean = String(rawRoute).split('?')[0].split('#')[0];
|
||||||
|
const trimmed = clean.replace(/\/+$/, '');
|
||||||
|
return trimmed || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} PinnedAppletState
|
||||||
|
* @property {Record<string, string[]>} pinnedByRoute Mapping of normalized route -> array of applet file paths
|
||||||
|
* @property {'idle'|'loading'|'ready'} status
|
||||||
|
* @property {string|null} activeExecution Currently active applet fileName to execute in dialog
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {PinnedAppletState} */
|
||||||
|
const initialState = {
|
||||||
|
pinnedByRoute: {},
|
||||||
|
status: 'idle',
|
||||||
|
activeExecution: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pinnedAppletAutomat;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Check if an applet is pinned to the specified route.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} rawRoute
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isPinned(fileName, rawRoute) {
|
||||||
|
if (!fileName) return false;
|
||||||
|
const route = normalizeRoute(rawRoute);
|
||||||
|
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||||
|
return Array.isArray(list) && list.includes(fileName);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all applet file paths pinned to the specified route.
|
||||||
|
* @param {string} rawRoute
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getPinnedForRoute(rawRoute) {
|
||||||
|
const route = normalizeRoute(rawRoute);
|
||||||
|
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin an applet to a route.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} rawRoute
|
||||||
|
*/
|
||||||
|
pinApplet(fileName, rawRoute) {
|
||||||
|
if (!fileName) return;
|
||||||
|
const route = normalizeRoute(rawRoute);
|
||||||
|
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||||
|
const list = current[route] ? [...current[route]] : [];
|
||||||
|
if (!list.includes(fileName)) {
|
||||||
|
list.push(fileName);
|
||||||
|
const next = { ...current, [route]: list };
|
||||||
|
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||||
|
void actions.save(route);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin an applet from a route.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} rawRoute
|
||||||
|
*/
|
||||||
|
unpinApplet(fileName, rawRoute) {
|
||||||
|
if (!fileName) return;
|
||||||
|
const route = normalizeRoute(rawRoute);
|
||||||
|
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||||
|
const list = current[route] ? current[route].filter((f) => f !== fileName) : [];
|
||||||
|
const next = { ...current };
|
||||||
|
if (list.length > 0) {
|
||||||
|
next[route] = list;
|
||||||
|
} else {
|
||||||
|
delete next[route];
|
||||||
|
}
|
||||||
|
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||||
|
void actions.save(route);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle pin status for an applet on a route.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} rawRoute
|
||||||
|
* @returns {boolean} New pinned status
|
||||||
|
*/
|
||||||
|
togglePin(fileName, rawRoute) {
|
||||||
|
if (!fileName) return false;
|
||||||
|
const route = normalizeRoute(rawRoute);
|
||||||
|
const isCurrentlyPinned = actions.isPinned(fileName, route);
|
||||||
|
if (isCurrentlyPinned) {
|
||||||
|
actions.unpinApplet(fileName, route);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
actions.pinApplet(fileName, route);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open dialog to execute the specified applet.
|
||||||
|
* @param {string} fileName
|
||||||
|
*/
|
||||||
|
executeApplet(fileName) {
|
||||||
|
pinnedAppletAutomat.setState({ activeExecution: fileName });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the execution dialog.
|
||||||
|
*/
|
||||||
|
closeExecution() {
|
||||||
|
pinnedAppletAutomat.setState({ activeExecution: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load pinned applets from company settings namespace and localStorage.
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
|
if (!companyId) return;
|
||||||
|
|
||||||
|
pinnedAppletAutomat.setState({ status: 'loading' });
|
||||||
|
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||||
|
let loadedMap = null;
|
||||||
|
|
||||||
|
// Fast-path: Synchronous read from localStorage to eliminate blank flash
|
||||||
|
try {
|
||||||
|
const rawLocal = localStorage.getItem(storageKey);
|
||||||
|
if (rawLocal) {
|
||||||
|
loadedMap = JSON.parse(rawLocal);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[pinnedAppletAutomat] localStorage read failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedMap && typeof loadedMap === 'object') {
|
||||||
|
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network read: Try company settings API with 'applet.pinned' namespace
|
||||||
|
try {
|
||||||
|
const res = await settingApi.get('applet.pinned');
|
||||||
|
const val = res?.setting?.value;
|
||||||
|
if (val) {
|
||||||
|
const parsed = JSON.parse(val);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
loadedMap = { ...(loadedMap || {}), ...parsed };
|
||||||
|
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(loadedMap));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore if key is not yet stored on backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also scan company settings list for any keys in namespace 'applet.pinned:'
|
||||||
|
try {
|
||||||
|
const listRes = await settingApi.list({ frontendOnly: true });
|
||||||
|
const settings = Array.isArray(listRes?.settings) ? listRes.settings : [];
|
||||||
|
let merged = false;
|
||||||
|
const current = { ...(loadedMap || pinnedAppletAutomat.state.pinnedByRoute) };
|
||||||
|
|
||||||
|
for (const s of settings) {
|
||||||
|
if (s.key === 'applet.pinned' && s.value) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s.value);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
Object.assign(current, parsed);
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} else if (s.key && s.key.startsWith('applet.pinned:') && s.value) {
|
||||||
|
const route = s.key.slice('applet.pinned:'.length);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s.value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
current[route] = parsed;
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged) {
|
||||||
|
pinnedAppletAutomat.setState({ pinnedByRoute: current, status: 'ready' });
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(current));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore network errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pinnedAppletAutomat.setState({ status: 'ready' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist pinned applets into company settings namespace and localStorage.
|
||||||
|
* @param {string} [route] Optional route updated
|
||||||
|
*/
|
||||||
|
async save(route = null) {
|
||||||
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
|
const { pinnedByRoute } = pinnedAppletAutomat.state;
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(pinnedByRoute));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[pinnedAppletAutomat] localStorage write failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert into company settings namespace 'applet.pinned'
|
||||||
|
try {
|
||||||
|
await settingApi.upsert('applet.pinned', {
|
||||||
|
value: JSON.stringify(pinnedByRoute),
|
||||||
|
frontend: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Backend key validation fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also upsert route-specific key in namespace
|
||||||
|
if (route) {
|
||||||
|
try {
|
||||||
|
const routeKey = `applet.pinned:${route}`;
|
||||||
|
const routeVal = JSON.stringify(pinnedByRoute[route] || []);
|
||||||
|
await settingApi.upsert(routeKey, {
|
||||||
|
value: routeVal,
|
||||||
|
frontend: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset on logout or company switch.
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
pinnedAppletAutomat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinned Applet state container.
|
||||||
|
* @type {Automat<PinnedAppletState>}
|
||||||
|
*/
|
||||||
|
pinnedAppletAutomat = new Automat(initialState, actions, { name: 'pinnedApplet' });
|
||||||
|
|
||||||
|
// Subscribe to auth lifecycle
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
|
if (auth.status === 'authed' && companyId) {
|
||||||
|
void pinnedAppletAutomat.actions.load();
|
||||||
|
} else if (auth.status === 'anon') {
|
||||||
|
pinnedAppletAutomat.actions.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-load if auth is already established when module loads
|
||||||
|
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
||||||
|
void pinnedAppletAutomat.actions.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pinnedAppletAutomat;
|
||||||
|
export const {
|
||||||
|
isPinned,
|
||||||
|
getPinnedForRoute,
|
||||||
|
pinApplet,
|
||||||
|
unpinApplet,
|
||||||
|
togglePin,
|
||||||
|
executeApplet,
|
||||||
|
closeExecution,
|
||||||
|
} = pinnedAppletAutomat.actions;
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file posterminalAutomat.js
|
|
||||||
* @description Manages registered Point-of-Sale (POS) hardware terminals and
|
|
||||||
* their enabled category mappings for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
* - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading.
|
|
||||||
* - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 = authAutomat.state.user?.selectedCompanyId ?? 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;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a posterminal.
|
|
||||||
* @param {number} terminalId
|
|
||||||
* @param {object} patch
|
|
||||||
*/
|
|
||||||
async update(terminalId, patch) {
|
|
||||||
const res = await posterminalApi.update(terminalId, patch);
|
|
||||||
const updated = res.posterminal;
|
|
||||||
posterminalAutomat.setState({
|
|
||||||
terminals: posterminalAutomat.state.terminals.map((t) =>
|
|
||||||
t.id === terminalId ? { ...t, ...updated } : t
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file propertyAutomat.js
|
|
||||||
* @description Manages catalog product property names (custom metadata definitions)
|
|
||||||
* for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
* - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event.
|
|
||||||
* - UI / Settings: Mutating actions (`createProperty`, `updateProperty`,
|
|
||||||
* `deleteProperty`, `reorderProperties`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import { propertyname as propertynameApi } from '../api';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} PropertyName
|
|
||||||
* @property {number} id
|
|
||||||
* @property {string} name
|
|
||||||
* @property {number} sort
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} PropertyState
|
|
||||||
* @property {PropertyName[]} properties
|
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
|
||||||
* @property {string|null} error
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {PropertyState} */
|
|
||||||
const initialState = {
|
|
||||||
properties: [],
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeProperties(properties) {
|
|
||||||
if (!Array.isArray(properties)) return [];
|
|
||||||
return [...properties]
|
|
||||||
.map((p) => ({
|
|
||||||
...p,
|
|
||||||
exclusive: Boolean(p.exclusive),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
|
||||||
if (sDiff !== 0) return sDiff;
|
|
||||||
return (a.id ?? 0) - (b.id ?? 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let propertyAutomat;
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/** Load all property names for the selected company. */
|
|
||||||
async load() {
|
|
||||||
propertyAutomat.setState({ status: 'loading', error: null });
|
|
||||||
try {
|
|
||||||
const res = await propertynameApi.list();
|
|
||||||
const properties = normalizeProperties(res.propertynames);
|
|
||||||
propertyAutomat.setState({ properties, status: 'succeeded' });
|
|
||||||
return properties;
|
|
||||||
} catch (err) {
|
|
||||||
propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a property name.
|
|
||||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
|
||||||
*/
|
|
||||||
async createProperty(body) {
|
|
||||||
const res = await propertynameApi.create(body);
|
|
||||||
await actions.load();
|
|
||||||
return res.propertyname;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a property name.
|
|
||||||
* @param {number} id
|
|
||||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
|
||||||
*/
|
|
||||||
async updateProperty(id, body) {
|
|
||||||
const res = await propertynameApi.update(id, body);
|
|
||||||
await actions.load();
|
|
||||||
return res.propertyname;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a property name.
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
async deleteProperty(id) {
|
|
||||||
await propertynameApi.delete(id);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder property names.
|
|
||||||
* @param {number[]} propertynameIds
|
|
||||||
*/
|
|
||||||
async reorderProperties(propertynameIds) {
|
|
||||||
await propertynameApi.order(propertynameIds);
|
|
||||||
await actions.load();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Reset to idle state (called on logout / company switch). */
|
|
||||||
reset() {
|
|
||||||
propertyAutomat.setState(initialState);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global property state container.
|
|
||||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
|
||||||
*
|
|
||||||
* @type {Automat<PropertyState>}
|
|
||||||
*/
|
|
||||||
propertyAutomat = new Automat(initialState, actions, { name: 'property' });
|
|
||||||
|
|
||||||
// Selectors
|
|
||||||
export function selectProperties() {
|
|
||||||
return propertyAutomat.state.properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectPropertyStatus() {
|
|
||||||
return propertyAutomat.state.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to auth changes
|
|
||||||
authAutomat.subscribe((auth) => {
|
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
|
||||||
if (auth.status === 'authed' && companyId) {
|
|
||||||
void propertyAutomat.actions.load();
|
|
||||||
} else if (auth.status === 'anon') {
|
|
||||||
propertyAutomat.actions.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default propertyAutomat;
|
|
||||||
File diff suppressed because it is too large
Load Diff
1005
src/automats/retoureAutomat.js
Normal file
1005
src/automats/retoureAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
1162
src/automats/shipmentAutomat.js
Normal file
1162
src/automats/shipmentAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* @file sseAutomat.js
|
* @file sseAutomat.js
|
||||||
* @description Central Server-Sent Events hub managing the single persistent
|
* @description Central Server-Sent Events hub managing the single persistent
|
||||||
* `GET /api/events` connection and fanning mutation signals into domain automats.
|
* `GET /api/events` connection and fanning mutation signals into active automats.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — reconnects stream when
|
* - `authAutomat`: Connects stream when `hasAuth` is true (and reconnects when
|
||||||
* `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout).
|
* `selectedCompanyId` changes); disconnects when `hasAuth` is false (logout).
|
||||||
*
|
*
|
||||||
* Downstream Triggers:
|
* Downstream Triggers:
|
||||||
* Dispatches real-time SSE mutation events into specific automats:
|
* Dispatches real-time SSE mutation events into active automats without eagerly
|
||||||
* - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-<sku>` signals.
|
* importing or instantiating unmounted stores:
|
||||||
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`):
|
* - `companyAutomat`: Refreshes warehouses, carriers, attributes, properties,
|
||||||
* Reloads SKU-keyed views on `article-<sku>` signals.
|
* suppliers, and brands when `companyAutomat` is active.
|
||||||
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()`
|
* - `categoryAutomat`: Refreshes category tree and open category views on `'category'`.
|
||||||
* on `'category'` SSE events.
|
* - `articleIndexAutomat`: Refreshes derived article index on `'articles'` / `'warehouse'`.
|
||||||
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events;
|
* - `artikelAutomat` & `articleDetailAutomat`: Refreshes watched SKU on `article-<sku>`.
|
||||||
* the index is derived from the reloaded category views, so no extra request.
|
* - `orderAutomat`, `quoteAutomat`, `shipmentAutomat`, `deliveryAutomat`, `retoureAutomat`:
|
||||||
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events.
|
* Reloads when those automats are active.
|
||||||
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
|
|
||||||
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
|
|
||||||
* - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
import artikelAutomat from './artikelAutomat';
|
|
||||||
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||||
import { articleIndexAutomat } from './articleIndexAutomat';
|
|
||||||
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
|
|
||||||
import warehouseAutomat from './warehouseAutomat';
|
|
||||||
import attributeAutomat from './attributeAutomat';
|
|
||||||
import propertyAutomat from './propertyAutomat';
|
|
||||||
import supplierAutomat from './supplierAutomat';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} SseState
|
* @typedef {Object} SseState
|
||||||
@@ -39,10 +29,10 @@ import supplierAutomat from './supplierAutomat';
|
|||||||
* @property {string|null} companyId Company the stream is bound to.
|
* @property {string|null} companyId Company the stream is bound to.
|
||||||
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
|
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
|
||||||
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
||||||
|
* @property {{id: number, type: string, data: string, lastEventId: string, time: number}|null} lastEvent Most recent event.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EVENT_HISTORY_LIMIT = 200;
|
const EVENT_HISTORY_LIMIT = 200;
|
||||||
|
|
||||||
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||||
|
|
||||||
function createClientId() {
|
function createClientId() {
|
||||||
@@ -56,8 +46,6 @@ function createClientId() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
||||||
* Created when the automat module is evaluated so every `EventSource` in this
|
|
||||||
* tab shares the same `cid`.
|
|
||||||
*
|
*
|
||||||
* @returns {string} Client id.
|
* @returns {string} Client id.
|
||||||
*/
|
*/
|
||||||
@@ -74,7 +62,6 @@ export function getSseClientId() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Created once per tab at automat creation time and stored to sessionStorage.
|
|
||||||
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
||||||
|
|
||||||
/** @type {SseState} */
|
/** @type {SseState} */
|
||||||
@@ -83,6 +70,7 @@ const initialState = {
|
|||||||
companyId: null,
|
companyId: null,
|
||||||
clientId: sseClientId,
|
clientId: sseClientId,
|
||||||
events: [],
|
events: [],
|
||||||
|
lastEvent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Event name prefix for per-article signals. */
|
/** Event name prefix for per-article signals. */
|
||||||
@@ -91,18 +79,20 @@ const ARTICLE_EVENT_PREFIX = 'article-';
|
|||||||
let sseAutomat;
|
let sseAutomat;
|
||||||
/** @type {EventSource | null} */
|
/** @type {EventSource | null} */
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
|
/** @type {AbortController | null} */
|
||||||
|
let streamAbortController = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handlers for statically named events (`category`, `articles`, …).
|
* Handlers for event subscriptions (`category`, `articles`, `article-*`, …).
|
||||||
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>}
|
* @type {Map<string, Set<{handler: Function, detach: (() => void)|null}>>}
|
||||||
*/
|
*/
|
||||||
const handlers = new Map();
|
const handlers = new Map();
|
||||||
let nextEventId = 0;
|
let nextEventId = 0;
|
||||||
const seenEvents = new WeakSet();
|
const seenEvents = new WeakSet();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
* Handlers per watched SKU.
|
||||||
* at least one view is open on that SKU.
|
|
||||||
* @type {Map<string, Set<(sku: string) => void>>}
|
* @type {Map<string, Set<(sku: string) => void>>}
|
||||||
*/
|
*/
|
||||||
const articleWatchers = new Map();
|
const articleWatchers = new Map();
|
||||||
@@ -114,25 +104,41 @@ function recordEvent(event) {
|
|||||||
const entry = {
|
const entry = {
|
||||||
id: ++nextEventId,
|
id: ++nextEventId,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
data: typeof event.data === 'string' ? event.data : '',
|
data: typeof event.data === 'string' ? event.data : (event.data ? JSON.stringify(event.data) : ''),
|
||||||
lastEventId: event.lastEventId ?? '',
|
lastEventId: event.lastEventId ?? '',
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
};
|
};
|
||||||
sseAutomat.setState((state) => ({
|
sseAutomat.setState((state) => ({
|
||||||
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
|
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
|
||||||
|
lastEvent: entry,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTrackedListener(es, type, handler) {
|
function clearReconnectTimer() {
|
||||||
const listener = (event) => {
|
if (reconnectTimer) {
|
||||||
recordEvent(event);
|
clearTimeout(reconnectTimer);
|
||||||
handler(event);
|
reconnectTimer = null;
|
||||||
};
|
}
|
||||||
es.addEventListener(type, listener);
|
}
|
||||||
return () => es.removeEventListener(type, listener);
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
clearReconnectTimer();
|
||||||
|
const auth = authAutomat.state;
|
||||||
|
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
|
||||||
|
if (!hasAuth) return;
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
clearReconnectTimer();
|
||||||
|
if (streamAbortController) {
|
||||||
|
streamAbortController.abort();
|
||||||
|
streamAbortController = null;
|
||||||
|
}
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
console.log('[sse] disconnect');
|
console.log('[sse] disconnect');
|
||||||
try {
|
try {
|
||||||
@@ -140,21 +146,155 @@ function disconnect() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
if (sseAutomat.state.status !== 'disconnected') {
|
if (sseAutomat && sseAutomat.state.status !== 'disconnected') {
|
||||||
sseAutomat.setState({ status: 'disconnected' });
|
sseAutomat.setState({ status: 'disconnected' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches every registered handler to a freshly opened stream.
|
* Dispatches an incoming event through registered handlers and article watchers.
|
||||||
*
|
*
|
||||||
* @param {EventSource} es Open event stream.
|
* @param {{ type: string, data: any, lastEventId?: string }} event
|
||||||
* @returns {void}
|
|
||||||
*/
|
*/
|
||||||
|
function dispatchSseEvent(event) {
|
||||||
|
recordEvent(event);
|
||||||
|
|
||||||
|
const eventType = String(event.type);
|
||||||
|
|
||||||
|
// 1. Built-in system handling (e.g. user / company switch sync)
|
||||||
|
if (eventType === 'user') {
|
||||||
|
try {
|
||||||
|
const payload = typeof event.data === 'string' ? JSON.parse(event.data || '{}') : (event.data || {});
|
||||||
|
const nextCompanyId = payload.selectedCompanyId ?? null;
|
||||||
|
|
||||||
|
if (authAutomat.state.user?.selectedCompanyId !== nextCompanyId) {
|
||||||
|
authAutomat.setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user: prev.user ? { ...prev.user, selectedCompanyId: nextCompanyId } : prev.user,
|
||||||
|
hasCompany: Boolean((prev.hasAuth ?? (prev.status === 'authed' && prev.user)) && nextCompanyId != null),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sse] failed to handle user event', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Dispatch to exact handlers registered via on(eventType, handler)
|
||||||
|
const exactSet = handlers.get(eventType);
|
||||||
|
if (exactSet && exactSet.size > 0) {
|
||||||
|
for (const entry of exactSet) {
|
||||||
|
try {
|
||||||
|
entry.handler(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[sse] error in handler for ${eventType}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback direct dispatch if store didn't subscribe yet
|
||||||
|
if (eventType === 'articles') {
|
||||||
|
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||||
|
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'category') {
|
||||||
|
void Automat.get('category')?.actions?.loadTree?.().catch(() => {});
|
||||||
|
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||||
|
} else if (eventType === 'company') {
|
||||||
|
void Automat.get('company')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'warehouse') {
|
||||||
|
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
|
||||||
|
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||||
|
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'supplier') {
|
||||||
|
void Automat.get('company')?.actions?.reloadSuppliers?.().catch(() => {});
|
||||||
|
} else if (eventType === 'brand') {
|
||||||
|
void Automat.get('company')?.actions?.loadBrands?.(true).catch(() => {});
|
||||||
|
} else if (eventType === 'unit') {
|
||||||
|
void Automat.get('company')?.actions?.loadUnits?.().catch(() => {});
|
||||||
|
} else if (eventType === 'carrier') {
|
||||||
|
void Automat.get('company')?.actions?.reloadCarriers?.().catch(() => {});
|
||||||
|
} else if (eventType === 'posterminal') {
|
||||||
|
void Automat.get('company')?.actions?.loadTerminals?.().catch(() => {});
|
||||||
|
} else if (eventType === 'customergroup') {
|
||||||
|
void Automat.get('company')?.actions?.reloadCustomergroups?.().catch(() => {});
|
||||||
|
} else if (eventType === 'propertyname') {
|
||||||
|
void Automat.get('company')?.actions?.loadProperties?.().catch(() => {});
|
||||||
|
} else if (eventType === 'attributename') {
|
||||||
|
void Automat.get('company')?.actions?.loadAttributes?.().catch(() => {});
|
||||||
|
} else if (eventType === 'account') {
|
||||||
|
void Automat.get('company')?.actions?.reloadAccounts?.().catch(() => {});
|
||||||
|
} else if (eventType === 'order') {
|
||||||
|
void Automat.get('order')?.actions?.reload?.().catch(() => {});
|
||||||
|
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'delivery') {
|
||||||
|
void Automat.get('delivery')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'retoure') {
|
||||||
|
void Automat.get('retoure')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'quote') {
|
||||||
|
void Automat.get('quote')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'shipment') {
|
||||||
|
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dispatch to article-specific handlers if it starts with article-
|
||||||
|
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
|
||||||
|
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
|
||||||
|
const lowerSku = sku.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Watchers registered via watchArticle(sku, handler)
|
||||||
|
const watchers = articleWatchers.get(lowerSku) || articleWatchers.get(sku);
|
||||||
|
if (watchers) {
|
||||||
|
for (const handler of watchers) {
|
||||||
|
try {
|
||||||
|
handler(sku);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[sse] error in article watcher for ${sku}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article wildcard handlers (registered via on('article-*', ...) or onArticle(...))
|
||||||
|
const articleWildcard = handlers.get('article-*');
|
||||||
|
if (articleWildcard) {
|
||||||
|
for (const entry of articleWildcard) {
|
||||||
|
try {
|
||||||
|
entry.handler(sku, event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sse] error in article-* handler:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single article detail & history/supplier caches invalidation
|
||||||
|
invalidateArticle(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Wildcard handlers registered via on('*', handler)
|
||||||
|
const allWildcard = handlers.get('*');
|
||||||
|
if (allWildcard) {
|
||||||
|
for (const entry of allWildcard) {
|
||||||
|
try {
|
||||||
|
entry.handler(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sse] error in * handler:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTrackedListener(es, type, handler) {
|
||||||
|
const listener = (event) => {
|
||||||
|
dispatchSseEvent(event);
|
||||||
|
handler(event);
|
||||||
|
};
|
||||||
|
es.addEventListener(type, listener);
|
||||||
|
return () => es.removeEventListener(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
function attachHandlers(es) {
|
function attachHandlers(es) {
|
||||||
for (const [event, set] of handlers) {
|
for (const [event, set] of handlers) {
|
||||||
|
if (event === '*' || event === 'article-*') continue;
|
||||||
for (const entry of set) {
|
for (const entry of set) {
|
||||||
entry.detach = addTrackedListener(es, event, entry.handler);
|
entry.detach = addTrackedListener(es, event, (ev) => dispatchSseEvent(ev));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [sku] of articleWatchers) {
|
for (const [sku] of articleWatchers) {
|
||||||
@@ -162,101 +302,178 @@ function attachHandlers(es) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the single `article-<sku>` listener for a watched SKU.
|
|
||||||
*
|
|
||||||
* The listener resolves the handler set at call time, so a SKU that lost all
|
|
||||||
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
|
||||||
* reclaimed whenever the stream is rebuilt on a company switch.
|
|
||||||
*
|
|
||||||
* @param {string} sku Article SKU.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function attachArticleListener(sku) {
|
function attachArticleListener(sku) {
|
||||||
if (!eventSource) return;
|
if (!eventSource) return;
|
||||||
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
||||||
const set = articleWatchers.get(sku);
|
|
||||||
if (!set || set.size === 0) return;
|
|
||||||
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
||||||
for (const handler of set) handler(changed);
|
const set = articleWatchers.get(sku);
|
||||||
|
if (set) {
|
||||||
|
for (const handler of set) handler(changed);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
async function startFetchStream(url, companyId) {
|
||||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
if (streamAbortController) {
|
||||||
if (companyId == null) {
|
streamAbortController.abort();
|
||||||
disconnect();
|
streamAbortController = null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
streamAbortController = controller;
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
const url = new URL('/api/events', window.location.origin);
|
try {
|
||||||
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
const res = await fetch(url.href, {
|
||||||
console.log(`[sse] connecting`);
|
credentials: 'include',
|
||||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
sseAutomat.setState({ status: 'disconnected' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`SSE HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
sseAutomat.setState({ status: 'connected', companyId });
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let eventType = 'message';
|
||||||
|
let eventData = '';
|
||||||
|
let lastEventId = '';
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split(/\r\n|\r|\n/);
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === '') {
|
||||||
|
if (eventData || eventType !== 'message') {
|
||||||
|
dispatchSseEvent({
|
||||||
|
type: eventType,
|
||||||
|
data: eventData,
|
||||||
|
lastEventId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
eventType = 'message';
|
||||||
|
eventData = '';
|
||||||
|
} else if (line.startsWith(':')) {
|
||||||
|
continue;
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
eventType = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
const chunk = line.slice(5).trim();
|
||||||
|
eventData = eventData ? `${eventData}\n${chunk}` : chunk;
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
lastEventId = line.slice(3).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
console.error('[sse] stream error', err);
|
||||||
|
recordEvent({ type: 'error', data: err?.message || '', lastEventId: '' });
|
||||||
|
sseAutomat.setState({ status: 'error' });
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEventSource(url, companyId) {
|
||||||
const es = new EventSource(url.href, { withCredentials: true });
|
const es = new EventSource(url.href, { withCredentials: true });
|
||||||
|
eventSource = es;
|
||||||
// Category tree changed: refresh the tree and every open category view.
|
|
||||||
addTrackedListener(es, 'category', () => {
|
|
||||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
|
||||||
console.error('[sse] category tree refresh failed', err);
|
|
||||||
});
|
|
||||||
invalidateAllCategoryViews();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Article index changed: refresh the index and open category views.
|
|
||||||
addTrackedListener(es, 'articles', () => {
|
|
||||||
invalidateAllCategoryViews();
|
|
||||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
|
||||||
console.error('[sse] article index refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'warehouse', () => {
|
|
||||||
void warehouseAutomat.actions.reload().catch((err) => {
|
|
||||||
console.error('[sse] warehouse refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'attributename', () => {
|
|
||||||
void attributeAutomat.actions.load().catch((err) => {
|
|
||||||
console.error('[sse] attributename refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'propertyname', () => {
|
|
||||||
void propertyAutomat.actions.load().catch((err) => {
|
|
||||||
console.error('[sse] propertyname refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'supplier', () => {
|
|
||||||
void supplierAutomat.actions.reload().catch((err) => {
|
|
||||||
console.error('[sse] supplier refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'open', () => {
|
addTrackedListener(es, 'open', () => {
|
||||||
sseAutomat.setState({ status: 'connected' });
|
sseAutomat.setState({ status: 'connected', companyId });
|
||||||
});
|
});
|
||||||
|
|
||||||
addTrackedListener(es, 'message', () => {});
|
addTrackedListener(es, 'user', (event) => {
|
||||||
|
dispatchSseEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
// EventSource reconnects on its own; only surface the state.
|
addTrackedListener(es, 'message', (event) => {
|
||||||
es.onerror = () => {
|
dispatchSseEvent(event);
|
||||||
console.error('[sse] event stream error');
|
});
|
||||||
recordEvent({ type: 'error', data: '', lastEventId: '' });
|
|
||||||
|
es.onerror = (e) => {
|
||||||
|
console.error('[sse] event stream error', e);
|
||||||
|
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
|
||||||
sseAutomat.setState({ status: 'error' });
|
sseAutomat.setState({ status: 'error' });
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource = es;
|
|
||||||
attachHandlers(es);
|
attachHandlers(es);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #region connect
|
||||||
|
// MARK: opens GET /api/events stream when hasAuth is true
|
||||||
|
function connect() {
|
||||||
|
clearReconnectTimer();
|
||||||
|
const auth = authAutomat.state;
|
||||||
|
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
|
||||||
|
if (!hasAuth) {
|
||||||
|
disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const companyId =
|
||||||
|
auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null;
|
||||||
|
|
||||||
|
const url = new URL('/api/events', window.location.origin);
|
||||||
|
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||||
|
console.log('[sse] connecting');
|
||||||
|
sseAutomat.setState({ status: 'connecting', companyId });
|
||||||
|
|
||||||
|
if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
|
||||||
|
void startFetchStream(url, companyId);
|
||||||
|
} else {
|
||||||
|
startEventSource(url, companyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
let wasAuthed = false;
|
||||||
|
|
||||||
|
function handleAuthSync(auth) {
|
||||||
|
const isAuthed = Boolean(auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user));
|
||||||
|
const companyId =
|
||||||
|
isAuthed && auth?.user?.selectedCompanyId != null
|
||||||
|
? String(auth.user.selectedCompanyId)
|
||||||
|
: null;
|
||||||
|
const relogin = isAuthed && !wasAuthed;
|
||||||
|
wasAuthed = isAuthed;
|
||||||
|
|
||||||
|
if (!isAuthed) {
|
||||||
|
disconnect();
|
||||||
|
sseAutomat.setState({ companyId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
sseAutomat.setState({ companyId });
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
* Closes and reopens the event stream. Required after a company switch,
|
* Closes and reopens the event stream.
|
||||||
* because the stream is scoped to the selected company at connect time.
|
|
||||||
*
|
*
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -265,18 +482,25 @@ const actions = {
|
|||||||
connect();
|
connect();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes the SSE connection with `authAutomat` state.
|
||||||
|
*
|
||||||
|
* @param {import('./authAutomat').AuthState} authState
|
||||||
|
*/
|
||||||
|
syncWithAuth(authState) {
|
||||||
|
handleAuthSync(authState ?? authAutomat.state);
|
||||||
|
},
|
||||||
|
|
||||||
clearEvents() {
|
clearEvents() {
|
||||||
sseAutomat.setState({ events: [] });
|
sseAutomat.setState({ events: [], lastEvent: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a handler for a statically named backend event.
|
* Registers a handler for a named backend event or pattern.
|
||||||
|
* Supports exact event names (e.g. `category`, `articles`), `article-*` wildcard, or `*`.
|
||||||
*
|
*
|
||||||
* Registration order does not matter: handlers added while disconnected are
|
* @param {string} event Event name or pattern.
|
||||||
* attached on the next connect.
|
* @param {Function} handler Signal handler.
|
||||||
*
|
|
||||||
* @param {string} event Event name (e.g. `category`, `articles`).
|
|
||||||
* @param {(event: MessageEvent) => void} handler Signal handler.
|
|
||||||
* @returns {() => void} Unsubscribe callback.
|
* @returns {() => void} Unsubscribe callback.
|
||||||
*/
|
*/
|
||||||
on(event, handler) {
|
on(event, handler) {
|
||||||
@@ -289,7 +513,9 @@ const actions = {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { handler, detach: null };
|
entry = { handler, detach: null };
|
||||||
set.add(entry);
|
set.add(entry);
|
||||||
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler);
|
if (eventSource && event !== '*' && event !== 'article-*') {
|
||||||
|
entry.detach = addTrackedListener(eventSource, event, handler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
entry.detach?.();
|
entry.detach?.();
|
||||||
@@ -299,40 +525,61 @@ const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to `article-<sku>` signals for one SKU.
|
* Subscribes to any article mutation event (`article-<sku>`).
|
||||||
*
|
*
|
||||||
* The backend emits this event whenever that article changes. Exactly one
|
* @param {(sku: string, event: object) => void} handler Called with changed SKU.
|
||||||
* listener is attached per SKU while at least one caller is subscribed.
|
* @returns {() => void} Unsubscribe callback.
|
||||||
|
*/
|
||||||
|
onArticle(handler) {
|
||||||
|
return actions.on('article-*', (sku, event) => handler(sku, event));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to `article-<sku>` signals for one specific SKU.
|
||||||
*
|
*
|
||||||
* @param {string} sku Article SKU to watch.
|
* @param {string} sku Article SKU to watch.
|
||||||
* @param {(sku: string) => void} handler Called with the changed SKU.
|
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||||
* @returns {() => void} Unsubscribe callback.
|
* @returns {() => void} Unsubscribe callback.
|
||||||
*/
|
*/
|
||||||
watchArticle(sku, handler) {
|
watchArticle(sku, handler) {
|
||||||
const key = String(sku);
|
const rawKey = String(sku).trim();
|
||||||
let set = articleWatchers.get(key);
|
const lowerKey = rawKey.toLowerCase();
|
||||||
|
let set = articleWatchers.get(lowerKey);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
set = new Set();
|
set = new Set();
|
||||||
articleWatchers.set(key, set);
|
articleWatchers.set(lowerKey, set);
|
||||||
attachArticleListener(key);
|
attachArticleListener(rawKey);
|
||||||
}
|
}
|
||||||
set.add(handler);
|
set.add(handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = articleWatchers.get(key);
|
const current = articleWatchers.get(lowerKey);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
current.delete(handler);
|
current.delete(handler);
|
||||||
if (current.size === 0) articleWatchers.delete(key);
|
if (current.size === 0) articleWatchers.delete(lowerKey);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically dispatches an event through sseAutomat.
|
||||||
|
*
|
||||||
|
* @param {string} type Event type (e.g. 'article-ART-000001', 'articles').
|
||||||
|
* @param {any} [data] Event data payload.
|
||||||
|
* @param {string} [lastEventId] Optional event ID.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
dispatch(type, data = {}, lastEventId = '') {
|
||||||
|
dispatchSseEvent({
|
||||||
|
type,
|
||||||
|
data: typeof data === 'string' ? data : JSON.stringify(data),
|
||||||
|
lastEventId,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global `GET /api/events` connection.
|
* Global `GET /api/events` connection.
|
||||||
*
|
*
|
||||||
* Owns the single `EventSource` for the tab and dispatches signals into the
|
|
||||||
* domain automats. Reconnects whenever the selected company changes.
|
|
||||||
*
|
|
||||||
* @type {Automat<SseState>}
|
* @type {Automat<SseState>}
|
||||||
*/
|
*/
|
||||||
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||||
@@ -340,48 +587,23 @@ sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
|||||||
/**
|
/**
|
||||||
* Refreshes the cached article plus any open history/supplier view.
|
* Refreshes the cached article plus any open history/supplier view.
|
||||||
*
|
*
|
||||||
* Background refreshes stay silent: an article that is not on screen has no
|
|
||||||
* visible subscriber to notify, so failures are swallowed.
|
|
||||||
*
|
|
||||||
* @param {string} sku Article SKU.
|
* @param {string} sku Article SKU.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function invalidateArticle(sku) {
|
export function invalidateArticle(sku) {
|
||||||
const key = String(sku);
|
const key = String(sku);
|
||||||
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
const art = Automat.get('artikel');
|
||||||
|
if (art?.actions?.reloadArticle) {
|
||||||
|
void art.actions.reloadArticle(key).catch(() => {});
|
||||||
|
}
|
||||||
if (articleHistoryAutomat.has(key)) {
|
if (articleHistoryAutomat.has(key)) {
|
||||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||||
}
|
}
|
||||||
if (articleSupplierAutomat.has(key)) {
|
// Reload assigned suppliers and sync with companyAutomat
|
||||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect when the selected company changes; disconnect on logout.
|
authAutomat.subscribe(handleAuthSync);
|
||||||
// On logout the stream must close; on relogin (anon -> authed with a company)
|
handleAuthSync(authAutomat.state);
|
||||||
// the stream is re-established even if the company id matches the previous one.
|
|
||||||
let wasAuthed = false;
|
|
||||||
authAutomat.subscribe((auth) => {
|
|
||||||
const companyId =
|
|
||||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
|
||||||
? String(auth.user.selectedCompanyId)
|
|
||||||
: null;
|
|
||||||
const isAuthed = companyId != null;
|
|
||||||
const relogin = isAuthed && !wasAuthed;
|
|
||||||
wasAuthed = isAuthed;
|
|
||||||
|
|
||||||
if (!relogin && companyId === sseAutomat.state.companyId) return;
|
|
||||||
|
|
||||||
if (companyId == null) {
|
|
||||||
disconnect();
|
|
||||||
sseAutomat.setState({ companyId: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A different company (or a fresh login) means a different stream scope.
|
|
||||||
disconnect();
|
|
||||||
sseAutomat.setState({ companyId });
|
|
||||||
connect();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default sseAutomat;
|
export default sseAutomat;
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file supplierAutomat.js
|
|
||||||
* @description Manages cached supplier list (`GET /api/supplier`) for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
|
||||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
|
||||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event.
|
|
||||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import { supplier as supplierApi } from '../api';
|
|
||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Supplier
|
|
||||||
* @property {string|number} id Supplier identifier.
|
|
||||||
* @property {string} name Supplier name.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} SupplierState
|
|
||||||
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
|
|
||||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
|
||||||
* @property {Error|null} error Last load error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {SupplierState} */
|
|
||||||
const initialState = {
|
|
||||||
suppliers: [],
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let supplierAutomat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the company-scoped supplier list.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Supplier>>} Supplier list.
|
|
||||||
*/
|
|
||||||
async function fetchSuppliers() {
|
|
||||||
const data = await supplierApi.list();
|
|
||||||
requireObject(data, 'GET /api/supplier');
|
|
||||||
return requireArray(data.suppliers, 'suppliers');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
|
||||||
let inflightLoad = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the supplier list once, deduplicating concurrent callers.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
|
||||||
*/
|
|
||||||
function loadSuppliers() {
|
|
||||||
if (inflightLoad) return inflightLoad;
|
|
||||||
inflightLoad = (async () => {
|
|
||||||
supplierAutomat.setState({ status: 'loading', error: null });
|
|
||||||
try {
|
|
||||||
const suppliers = await fetchSuppliers();
|
|
||||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
|
||||||
return suppliers;
|
|
||||||
} catch (error) {
|
|
||||||
supplierAutomat.setState({ status: 'failed', error });
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
inflightLoad = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return inflightLoad;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/**
|
|
||||||
* Loads the supplier list, exposing a loading/failed lifecycle.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
|
||||||
*/
|
|
||||||
load() {
|
|
||||||
return loadSuppliers();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silently re-fetches the supplier list while keeping the current state.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
|
|
||||||
*/
|
|
||||||
async reload() {
|
|
||||||
try {
|
|
||||||
const suppliers = await fetchSuppliers();
|
|
||||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
|
||||||
return suppliers;
|
|
||||||
} catch (error) {
|
|
||||||
supplierAutomat.setState({ error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a supplier and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {{name: string}} body Creation payload.
|
|
||||||
* @returns {Promise<Supplier>} Created supplier.
|
|
||||||
*/
|
|
||||||
async create(body) {
|
|
||||||
const data = await supplierApi.create(body);
|
|
||||||
requireObject(data, 'POST /api/supplier');
|
|
||||||
requireObject(data.supplier, 'supplier');
|
|
||||||
requireDefined(data.supplier.id, 'supplier.id');
|
|
||||||
await supplierAutomat.actions.reload();
|
|
||||||
return data.supplier;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames a supplier and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {string|number} id Supplier identifier.
|
|
||||||
* @param {{name: string}} body Update payload.
|
|
||||||
* @returns {Promise<Supplier>} Updated supplier.
|
|
||||||
*/
|
|
||||||
async update(id, body) {
|
|
||||||
const data = await supplierApi.update(id, body);
|
|
||||||
requireObject(data, 'PATCH /api/supplier/:id');
|
|
||||||
requireObject(data.supplier, 'supplier');
|
|
||||||
requireDefined(data.supplier.id, 'supplier.id');
|
|
||||||
await supplierAutomat.actions.reload();
|
|
||||||
return data.supplier;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a supplier and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {string|number} id Supplier identifier.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async remove(id) {
|
|
||||||
await supplierApi.delete(id);
|
|
||||||
await supplierAutomat.actions.reload();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the cached list without a round-trip.
|
|
||||||
*
|
|
||||||
* @param {Array<Supplier>} suppliers Suppliers to cache.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setSuppliers(suppliers) {
|
|
||||||
supplierAutomat.setState({
|
|
||||||
suppliers: Array.isArray(suppliers) ? suppliers : [],
|
|
||||||
status: 'ready',
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgets the cached list and returns to the initial state.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
supplierAutomat.setState({ ...initialState });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Company-scoped supplier cache. Loads automatically once an authenticated
|
|
||||||
* session with a selected company exists and is cleared when auth is gone.
|
|
||||||
*
|
|
||||||
* @type {Automat<SupplierState>}
|
|
||||||
*/
|
|
||||||
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives the cache key from the auth state: the selected company while
|
|
||||||
* authenticated, otherwise `null`.
|
|
||||||
*
|
|
||||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
|
||||||
* @returns {string|null} Cache key.
|
|
||||||
*/
|
|
||||||
function authKey(authState) {
|
|
||||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
|
||||||
const companyId = authState.user.selectedCompanyId;
|
|
||||||
return companyId == null ? null : String(companyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastAuthKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the supplier list when auth/company becomes available and forgets it
|
|
||||||
* when the session ends.
|
|
||||||
*
|
|
||||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function syncWithAuth(authState) {
|
|
||||||
const key = authKey(authState);
|
|
||||||
if (key === lastAuthKey) return;
|
|
||||||
lastAuthKey = key;
|
|
||||||
if (key == null) {
|
|
||||||
supplierAutomat.actions.clear();
|
|
||||||
} else {
|
|
||||||
void supplierAutomat.actions.load().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
authAutomat.subscribe(syncWithAuth);
|
|
||||||
syncWithAuth(authAutomat.state);
|
|
||||||
|
|
||||||
export default supplierAutomat;
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file unitAutomat.js
|
|
||||||
* @description Manages allowable units of measure (Stück, kg, Liter, etc.)
|
|
||||||
* for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
|
||||||
* - UI / Settings: Mutating actions (`upsert`, `delete`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
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,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file userSettingsAutomat.js
|
|
||||||
* @description Manages client-side user UI layout preferences (attachment viewer
|
|
||||||
* position, chat drawer width). Persisted in IndexedDB.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - UI / User layout preferences: Calls to `setViewerPosition(position)` and
|
|
||||||
* `setChatDrawerWidth(width)`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {'top'|'right'|'below'} ViewerPosition
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UserSettingsState
|
|
||||||
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
|
||||||
* attachment viewer (text editor / PDF preview) relative to the article list.
|
|
||||||
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {UserSettingsState} */
|
|
||||||
const initialState = {
|
|
||||||
viewerPosition: 'top',
|
|
||||||
chatDrawerWidth: 460,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted per-user UI preferences. Unlike the other automats this one is
|
|
||||||
* backed by IndexedDB (`persist: true`) so settings survive reloads and
|
|
||||||
* sessions.
|
|
||||||
*
|
|
||||||
* @type {Automat<UserSettingsState>}
|
|
||||||
*/
|
|
||||||
const userSettingsAutomat = new Automat(
|
|
||||||
initialState,
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Set the docking position of the attachment viewer.
|
|
||||||
*
|
|
||||||
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setViewerPosition(position) {
|
|
||||||
if (position !== 'top' && position !== 'right' && position !== 'below') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userSettingsAutomat.setState({ viewerPosition: position });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the width of the chat drawer in pixels.
|
|
||||||
*
|
|
||||||
* @param {number} width
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setChatDrawerWidth(width) {
|
|
||||||
const num = Number(width);
|
|
||||||
if (Number.isNaN(num) || num < 280) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'userSettings',
|
|
||||||
persist: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default userSettingsAutomat;
|
|
||||||
export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions;
|
|
||||||
export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460;
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { vfs as vfsApi } from '../api';
|
import { vfs as vfsApi } from '../api';
|
||||||
|
import { isAppletFile, parseAppletSchemas } from '../components/vfs/vfsUtils';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
import sseAutomat from './sseAutomat';
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import sseAutomat from './sseAutomat';
|
|||||||
* @property {boolean} isText Whether the file can be edited in the text editor
|
* @property {boolean} isText Whether the file can be edited in the text editor
|
||||||
* @property {boolean} isImage Whether the file can be rendered as an image
|
* @property {boolean} isImage Whether the file can be rendered as an image
|
||||||
* @property {boolean} isPdf Whether the file is a PDF
|
* @property {boolean} isPdf Whether the file is a PDF
|
||||||
|
* @property {string} [description] Applet description declared in the @applet-description header
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +73,7 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let vfsAutomat;
|
let vfsAutomat;
|
||||||
|
const appletDescriptionCache = new Map();
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +123,17 @@ const actions = {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await vfsApi.list();
|
const res = await vfsApi.list();
|
||||||
const files = Array.isArray(res?.files) ? res.files : [];
|
const rawFiles = Array.isArray(res?.files) ? res.files : [];
|
||||||
|
const files = rawFiles.map((f) => {
|
||||||
|
if (f.description) {
|
||||||
|
appletDescriptionCache.set(f.name, f.description);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
if (appletDescriptionCache.has(f.name)) {
|
||||||
|
return { ...f, description: appletDescriptionCache.get(f.name) };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
const currentSelected = vfsAutomat.state.selectedFileName;
|
const currentSelected = vfsAutomat.state.selectedFileName;
|
||||||
const updatedSelectedFile = currentSelected
|
const updatedSelectedFile = currentSelected
|
||||||
? files.find((f) => f.name === currentSelected) || null
|
? files.find((f) => f.name === currentSelected) || null
|
||||||
@@ -132,6 +145,10 @@ const actions = {
|
|||||||
loadingFiles: false,
|
loadingFiles: false,
|
||||||
loadError: null,
|
loadError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Asynchronously fetch descriptions for any applet files that don't have one yet
|
||||||
|
void actions.loadAppletDescriptions(files);
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[vfsAutomat] loadFiles failed:', err);
|
console.error('[vfsAutomat] loadFiles failed:', err);
|
||||||
@@ -143,6 +160,68 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously load description headers for applets missing cached descriptions.
|
||||||
|
* @param {VfsFile[]} [files]
|
||||||
|
*/
|
||||||
|
async loadAppletDescriptions(files) {
|
||||||
|
const list = files || vfsAutomat.state.files;
|
||||||
|
const missing = list.filter((f) => isAppletFile(f.name) && !f.description);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
await Promise.all(
|
||||||
|
missing.map(async (file) => {
|
||||||
|
try {
|
||||||
|
const text = await vfsApi.getText(file.name);
|
||||||
|
const { description } = parseAppletSchemas(text);
|
||||||
|
if (description) {
|
||||||
|
appletDescriptionCache.set(file.name, description);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore failures reading unreadable files
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
const nextFiles = vfsAutomat.state.files.map((f) => {
|
||||||
|
const desc = appletDescriptionCache.get(f.name);
|
||||||
|
return desc && f.description !== desc ? { ...f, description: desc } : f;
|
||||||
|
});
|
||||||
|
const currentSelected = vfsAutomat.state.selectedFileName;
|
||||||
|
const updatedSelectedFile = currentSelected
|
||||||
|
? nextFiles.find((f) => f.name === currentSelected) || null
|
||||||
|
: null;
|
||||||
|
vfsAutomat.setState({
|
||||||
|
files: nextFiles,
|
||||||
|
selectedFile: updatedSelectedFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually record or update an applet file's description in the state & cache.
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} description
|
||||||
|
*/
|
||||||
|
setAppletDescription(fileName, description) {
|
||||||
|
if (!fileName || !description) return;
|
||||||
|
appletDescriptionCache.set(fileName, description);
|
||||||
|
const nextFiles = vfsAutomat.state.files.map((f) =>
|
||||||
|
f.name === fileName ? { ...f, description } : f
|
||||||
|
);
|
||||||
|
const updatedSelectedFile =
|
||||||
|
vfsAutomat.state.selectedFileName === fileName
|
||||||
|
? { ...(vfsAutomat.state.selectedFile || {}), description }
|
||||||
|
: vfsAutomat.state.selectedFile;
|
||||||
|
vfsAutomat.setState({
|
||||||
|
files: nextFiles,
|
||||||
|
selectedFile: updatedSelectedFile,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a file for viewing or editing.
|
* Select a file for viewing or editing.
|
||||||
* @param {string|null} fileName
|
* @param {string|null} fileName
|
||||||
@@ -179,9 +258,18 @@ const actions = {
|
|||||||
const text = await vfsApi.getText(fileName);
|
const text = await vfsApi.getText(fileName);
|
||||||
// Only apply if still selected
|
// Only apply if still selected
|
||||||
if (vfsAutomat.state.selectedFileName === fileName) {
|
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||||
|
let updatedFile = file;
|
||||||
|
if (isAppletFile(fileName)) {
|
||||||
|
const { description } = parseAppletSchemas(text);
|
||||||
|
if (description) {
|
||||||
|
appletDescriptionCache.set(fileName, description);
|
||||||
|
updatedFile = { ...file, description };
|
||||||
|
}
|
||||||
|
}
|
||||||
vfsAutomat.setState({
|
vfsAutomat.setState({
|
||||||
fileContent: text,
|
fileContent: text,
|
||||||
originalContent: text,
|
originalContent: text,
|
||||||
|
selectedFile: updatedFile,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
loadingContent: false,
|
loadingContent: false,
|
||||||
});
|
});
|
||||||
@@ -224,6 +312,14 @@ const actions = {
|
|||||||
vfsAutomat.setState({ isSaving: true, saveError: null });
|
vfsAutomat.setState({ isSaving: true, saveError: null });
|
||||||
try {
|
try {
|
||||||
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
|
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
|
||||||
|
if (isAppletFile(selectedFileName)) {
|
||||||
|
const { description } = parseAppletSchemas(fileContent);
|
||||||
|
if (description) {
|
||||||
|
appletDescriptionCache.set(selectedFileName, description);
|
||||||
|
} else {
|
||||||
|
appletDescriptionCache.delete(selectedFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
vfsAutomat.setState({
|
vfsAutomat.setState({
|
||||||
originalContent: fileContent,
|
originalContent: fileContent,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
@@ -274,6 +370,7 @@ const actions = {
|
|||||||
if (!fileName) return;
|
if (!fileName) return;
|
||||||
try {
|
try {
|
||||||
await vfsApi.delete(fileName);
|
await vfsApi.delete(fileName);
|
||||||
|
appletDescriptionCache.delete(fileName);
|
||||||
if (vfsAutomat.state.selectedFileName === fileName) {
|
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||||
actions.selectFile(null);
|
actions.selectFile(null);
|
||||||
}
|
}
|
||||||
@@ -320,6 +417,11 @@ const actions = {
|
|||||||
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
|
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
|
||||||
try {
|
try {
|
||||||
const res = await vfsApi.rename(cleanOld, cleanNew);
|
const res = await vfsApi.rename(cleanOld, cleanNew);
|
||||||
|
if (appletDescriptionCache.has(cleanOld)) {
|
||||||
|
const desc = appletDescriptionCache.get(cleanOld);
|
||||||
|
appletDescriptionCache.delete(cleanOld);
|
||||||
|
appletDescriptionCache.set(cleanNew, desc);
|
||||||
|
}
|
||||||
const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
|
const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
|
||||||
await actions.loadFiles(true);
|
await actions.loadFiles(true);
|
||||||
if (isCurrentSelected) {
|
if (isCurrentSelected) {
|
||||||
@@ -379,6 +481,7 @@ const actions = {
|
|||||||
* Reset state (on logout or company switch).
|
* Reset state (on logout or company switch).
|
||||||
*/
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
|
appletDescriptionCache.clear();
|
||||||
vfsAutomat.setState({
|
vfsAutomat.setState({
|
||||||
files: [],
|
files: [],
|
||||||
loadingFiles: false,
|
loadingFiles: false,
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file warehouseAutomat.js
|
|
||||||
* @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company.
|
|
||||||
*
|
|
||||||
* Upstream Triggers:
|
|
||||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
|
||||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
|
||||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event.
|
|
||||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
|
||||||
import { warehouse as warehouseApi } from '../api';
|
|
||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Warehouse
|
|
||||||
* @property {string|number} id Warehouse identifier.
|
|
||||||
* @property {string} name Warehouse name.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} WarehouseState
|
|
||||||
* @property {Array<Warehouse>} warehouses Company-scoped warehouses.
|
|
||||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
|
||||||
* @property {Error|null} error Last load error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {WarehouseState} */
|
|
||||||
const initialState = {
|
|
||||||
warehouses: [],
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let warehouseAutomat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the company-scoped warehouse list.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Warehouse>>} Warehouse list.
|
|
||||||
*/
|
|
||||||
async function fetchWarehouses() {
|
|
||||||
const data = await warehouseApi.list();
|
|
||||||
requireObject(data, 'GET /api/warehouse');
|
|
||||||
return requireArray(data.warehouses, 'warehouses');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
|
||||||
let inflightLoad = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the warehouse list once, deduplicating concurrent callers.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
|
||||||
*/
|
|
||||||
function loadWarehouses() {
|
|
||||||
if (inflightLoad) return inflightLoad;
|
|
||||||
inflightLoad = (async () => {
|
|
||||||
warehouseAutomat.setState({ status: 'loading', error: null });
|
|
||||||
try {
|
|
||||||
const warehouses = await fetchWarehouses();
|
|
||||||
warehouseAutomat.setState({ warehouses, status: 'ready', error: null });
|
|
||||||
return warehouses;
|
|
||||||
} catch (error) {
|
|
||||||
warehouseAutomat.setState({ status: 'failed', error });
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
inflightLoad = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return inflightLoad;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
/**
|
|
||||||
* Loads the warehouse list, exposing a loading/failed lifecycle.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
|
||||||
*/
|
|
||||||
load() {
|
|
||||||
return loadWarehouses();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silently re-fetches the warehouse list while keeping the current state.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<Warehouse>>} Fresh warehouses.
|
|
||||||
*/
|
|
||||||
async reload() {
|
|
||||||
try {
|
|
||||||
const warehouses = await fetchWarehouses();
|
|
||||||
warehouseAutomat.setState({ warehouses, status: 'ready', error: null });
|
|
||||||
return warehouses;
|
|
||||||
} catch (error) {
|
|
||||||
warehouseAutomat.setState({ error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a warehouse and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {{name: string}} body Creation payload.
|
|
||||||
* @returns {Promise<Warehouse>} Created warehouse.
|
|
||||||
*/
|
|
||||||
async create(body) {
|
|
||||||
const data = await warehouseApi.create(body);
|
|
||||||
requireObject(data, 'POST /api/warehouse');
|
|
||||||
requireObject(data.warehouse, 'warehouse');
|
|
||||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
|
||||||
await warehouseAutomat.actions.reload();
|
|
||||||
return data.warehouse;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames a warehouse and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {string|number} id Warehouse identifier.
|
|
||||||
* @param {{name: string}} body Update payload.
|
|
||||||
* @returns {Promise<Warehouse>} Updated warehouse.
|
|
||||||
*/
|
|
||||||
async update(id, body) {
|
|
||||||
const data = await warehouseApi.update(id, body);
|
|
||||||
requireObject(data, 'PATCH /api/warehouse/:id');
|
|
||||||
requireObject(data.warehouse, 'warehouse');
|
|
||||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
|
||||||
await warehouseAutomat.actions.reload();
|
|
||||||
return data.warehouse;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a warehouse and refreshes the cached list.
|
|
||||||
*
|
|
||||||
* @param {string|number} id Warehouse identifier.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async remove(id) {
|
|
||||||
await warehouseApi.delete(id);
|
|
||||||
await warehouseAutomat.actions.reload();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a new sort order for all warehouses.
|
|
||||||
*
|
|
||||||
* @param {Array<string|number>} warehouseIds IDs in the desired order.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async reorder(warehouseIds) {
|
|
||||||
await warehouseApi.order({ warehouseIds });
|
|
||||||
await warehouseAutomat.actions.reload();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the cached list without a round-trip.
|
|
||||||
*
|
|
||||||
* @param {Array<Warehouse>} warehouses Warehouses to cache.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
setWarehouses(warehouses) {
|
|
||||||
warehouseAutomat.setState({
|
|
||||||
warehouses: Array.isArray(warehouses) ? warehouses : [],
|
|
||||||
status: 'ready',
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgets the cached list and returns to the initial state.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
warehouseAutomat.setState({ ...initialState });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Company-scoped warehouse cache. Loads automatically once an authenticated
|
|
||||||
* session with a selected company exists and is cleared when auth is gone.
|
|
||||||
*
|
|
||||||
* @type {Automat<WarehouseState>}
|
|
||||||
*/
|
|
||||||
warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives the cache key from the auth state: the selected company while
|
|
||||||
* authenticated, otherwise `null`.
|
|
||||||
*
|
|
||||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
|
||||||
* @returns {string|null} Cache key.
|
|
||||||
*/
|
|
||||||
function authKey(authState) {
|
|
||||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
|
||||||
const companyId = authState.user.selectedCompanyId;
|
|
||||||
return companyId == null ? null : String(companyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastAuthKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the warehouse list when auth/company becomes available and forgets it
|
|
||||||
* when the session ends.
|
|
||||||
*
|
|
||||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function syncWithAuth(authState) {
|
|
||||||
const key = authKey(authState);
|
|
||||||
if (key === lastAuthKey) return;
|
|
||||||
lastAuthKey = key;
|
|
||||||
if (key == null) {
|
|
||||||
warehouseAutomat.actions.clear();
|
|
||||||
} else {
|
|
||||||
void warehouseAutomat.actions.load().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
authAutomat.subscribe(syncWithAuth);
|
|
||||||
syncWithAuth(authAutomat.state);
|
|
||||||
|
|
||||||
export default warehouseAutomat;
|
|
||||||
@@ -59,6 +59,7 @@ class AppDialog extends Component {
|
|||||||
const {
|
const {
|
||||||
transitionDuration: _transitionDuration,
|
transitionDuration: _transitionDuration,
|
||||||
TransitionProps: _transitionProps,
|
TransitionProps: _transitionProps,
|
||||||
|
transitionProps: _lowerTransitionProps,
|
||||||
TransitionComponent: _transitionComponent,
|
TransitionComponent: _transitionComponent,
|
||||||
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
|
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
|
||||||
// of spreading onto Dialog/DOM where it triggers
|
// of spreading onto Dialog/DOM where it triggers
|
||||||
@@ -71,6 +72,7 @@ class AppDialog extends Component {
|
|||||||
} = this.props;
|
} = this.props;
|
||||||
void _transitionDuration;
|
void _transitionDuration;
|
||||||
void _transitionProps;
|
void _transitionProps;
|
||||||
|
void _lowerTransitionProps;
|
||||||
void _transitionComponent;
|
void _transitionComponent;
|
||||||
|
|
||||||
const userPaper = userSlotProps?.paper ?? {};
|
const userPaper = userSlotProps?.paper ?? {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, lazy, Suspense } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
@@ -8,11 +8,15 @@ import ButtonBase from '@mui/material/ButtonBase';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
|
import OnlinePrinterIcons from './OnlinePrinterIcons';
|
||||||
import HelpBadge from './HelpBadge';
|
import HelpBadge from './HelpBadge';
|
||||||
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
||||||
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
||||||
import { defaultCategoryPath } from './category/categoryRoute';
|
import { defaultCategoryPath } from './category/categoryRoute';
|
||||||
import { navigationAutomat } from '../automats';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import navigationAutomat from '../automats/navigationAutomat';
|
||||||
|
import { initPwa } from '../automats/pwaAutomat';
|
||||||
|
import PwaUpdateDialog from './PwaUpdateDialog';
|
||||||
import {
|
import {
|
||||||
NAV_ITEM_ACTIVE_ACCENT,
|
NAV_ITEM_ACTIVE_ACCENT,
|
||||||
NAV_SUB_ACTIVE,
|
NAV_SUB_ACTIVE,
|
||||||
@@ -20,7 +24,18 @@ import {
|
|||||||
NAV_SURFACE,
|
NAV_SURFACE,
|
||||||
} from '../colors';
|
} from '../colors';
|
||||||
import { ChatFab, ChatDrawer } from './chat';
|
import { ChatFab, ChatDrawer } from './chat';
|
||||||
import PageMinimap from './PageMinimap';
|
import { PinnedAppletFabs, PinnedAppletDialog } from './applet';
|
||||||
|
|
||||||
|
// Ensure companyAutomat is retained as part of the 1st-level navigation shell
|
||||||
|
void companyAutomat;
|
||||||
|
initPwa();
|
||||||
|
|
||||||
|
const BreakpointOverlay = 0 && import.meta.env.DEV
|
||||||
|
? lazy(() => import('./BreakpointOverlay'))
|
||||||
|
: null;
|
||||||
|
const ChunkCacheMonitor = import.meta.env.DEV
|
||||||
|
? lazy(() => import('../devtools/ChunkCacheMonitor'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
|
||||||
const SIDEBAR_WIDTH = 144;
|
const SIDEBAR_WIDTH = 144;
|
||||||
@@ -63,19 +78,37 @@ class AppSidebar extends PureComponent {
|
|||||||
<Box
|
<Box
|
||||||
component="nav"
|
component="nav"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
width: SIDEBAR_WIDTH,
|
width: { xs: '100%', lg: SIDEBAR_WIDTH },
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: { xs: 'row', lg: 'column' },
|
||||||
|
alignItems: { xs: 'center', lg: 'stretch' },
|
||||||
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
|
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
|
||||||
background: theme.nav.background,
|
background: theme.nav.background,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
zIndex: theme.zIndex.appBar || 1100,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
height: 'var(--df-vh, 100vh)',
|
height: { xs: 52, sm: 56, lg: 'var(--df-vh, 100vh)' },
|
||||||
|
px: { xs: 1, sm: 1.5, lg: 0 },
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
boxShadow: { xs: '0 2px 8px rgba(0,0,0,0.18)', lg: 'none' },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<List disablePadding sx={{ flexGrow: 1 }}>
|
<List
|
||||||
|
disablePadding
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: { xs: 'flex', lg: 'block' },
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: { xs: '100%', lg: 'auto' },
|
||||||
|
overflowX: { xs: 'auto', lg: 'visible' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
gap: { xs: 0.5, sm: 1, lg: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
|
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
|
||||||
const active = path === activeNav;
|
const active = path === activeNav;
|
||||||
|
|
||||||
@@ -83,6 +116,8 @@ class AppSidebar extends PureComponent {
|
|||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={path}
|
key={path}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
selected={active}
|
selected={active}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -107,12 +142,22 @@ class AppSidebar extends PureComponent {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
borderRadius: 0,
|
borderRadius: { xs: 1, lg: 0 },
|
||||||
px: 1.5,
|
width: { xs: 'auto', lg: '100%' },
|
||||||
py: 1.25,
|
height: { xs: '100%', lg: 'auto' },
|
||||||
borderLeft: '3px solid transparent',
|
px: { xs: active ? 1.25 : 1, sm: active ? 1.5 : 1.25, md: 1.5, lg: 1.25 },
|
||||||
|
py: { xs: 0.75, sm: 1, lg: 1.25 },
|
||||||
|
borderLeft: { xs: 'none', lg: '3px solid transparent' },
|
||||||
|
borderBottom: { xs: '3px solid transparent', lg: 'none' },
|
||||||
color: theme.nav.item,
|
color: theme.nav.item,
|
||||||
'& .MuiListItemIcon-root': { color: 'inherit' },
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiListItemIcon-root': {
|
||||||
|
color: 'inherit',
|
||||||
|
minWidth: { xs: 'auto', lg: 32 },
|
||||||
|
mr: active ? { xs: 0.75, lg: 0 } : { xs: 0, md: 0.75, lg: 0 },
|
||||||
|
justifyContent: { xs: 'center', lg: 'flex-start' },
|
||||||
|
},
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: theme.nav.itemHover,
|
bgcolor: theme.nav.itemHover,
|
||||||
color: theme.nav.itemHoverText,
|
color: theme.nav.itemHoverText,
|
||||||
@@ -121,7 +166,8 @@ class AppSidebar extends PureComponent {
|
|||||||
bgcolor: theme.nav.itemActive,
|
bgcolor: theme.nav.itemActive,
|
||||||
color: theme.nav.itemActiveText,
|
color: theme.nav.itemActiveText,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
borderLeftColor: theme.nav.itemActiveAccent,
|
borderLeftColor: { xs: 'transparent', lg: theme.nav.itemActiveAccent },
|
||||||
|
borderBottomColor: { xs: theme.nav.itemActiveAccent, lg: 'transparent' },
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: theme.nav.itemActive,
|
bgcolor: theme.nav.itemActive,
|
||||||
color: theme.nav.itemActiveText,
|
color: theme.nav.itemActiveText,
|
||||||
@@ -129,18 +175,45 @@ class AppSidebar extends PureComponent {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
<ListItemIcon>
|
||||||
<HelpBadge title={label} help={help} placement="right">
|
<HelpBadge title={label} help={help} placement="right">
|
||||||
<Icon />
|
<Icon />
|
||||||
</HelpBadge>
|
</HelpBadge>
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={label} />
|
<ListItemText
|
||||||
|
primary={label}
|
||||||
|
slotProps={{
|
||||||
|
primary: {
|
||||||
|
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
noWrap: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
m: { xs: 0, lg: 'initial' },
|
||||||
|
display: active ? 'block' : { xs: 'none', md: 'block' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<UserMenu />
|
<Box
|
||||||
|
sx={{
|
||||||
|
ml: { xs: 'auto', lg: 0 },
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'row', lg: 'column' },
|
||||||
|
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||||
|
width: { xs: 'auto', lg: '100%' },
|
||||||
|
gap: { xs: 0.5, lg: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OnlinePrinterIcons />
|
||||||
|
<UserMenu />
|
||||||
|
<ChatFab />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -184,10 +257,11 @@ class AppSubNav extends PureComponent {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: SUB_NAV_WIDTH,
|
width: { xs: 0, lg: SUB_NAV_WIDTH },
|
||||||
|
display: { xs: 'none', lg: 'block' },
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
ml: 3,
|
ml: { xs: 0, lg: 3 },
|
||||||
mr: 2,
|
mr: { xs: 0, lg: 2 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -197,21 +271,34 @@ class AppSubNav extends PureComponent {
|
|||||||
<Box
|
<Box
|
||||||
component="nav"
|
component="nav"
|
||||||
aria-label="Unternavigation"
|
aria-label="Unternavigation"
|
||||||
sx={{
|
sx={(theme) => ({
|
||||||
width: SUB_NAV_WIDTH,
|
width: { xs: '100%', sm: 72, md: SUB_NAV_WIDTH },
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: { xs: 'row', sm: 'column' },
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: { xs: 1, sm: 1.5 },
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: { xs: 52, sm: 56, lg: 0 },
|
||||||
|
zIndex: { xs: 1090, sm: 'auto' },
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
height: 'var(--df-vh, 100vh)',
|
height: {
|
||||||
py: 4,
|
xs: 'auto',
|
||||||
ml: 3,
|
sm: 'calc(var(--df-vh, 100vh) - 56px)',
|
||||||
mr: 2,
|
lg: 'var(--df-vh, 100vh)',
|
||||||
}}
|
},
|
||||||
|
py: { xs: 0.75, sm: 2, lg: 4 },
|
||||||
|
px: { xs: 1.5, sm: 0 },
|
||||||
|
ml: { xs: 0, sm: 2, lg: 3 },
|
||||||
|
mr: { xs: 0, sm: 1.5, lg: 2 },
|
||||||
|
overflowX: { xs: 'auto', sm: 'visible' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
bgcolor: { xs: theme.nav?.mainBackground || 'background.default', sm: 'transparent' },
|
||||||
|
borderBottom: { xs: '1px solid', sm: 'none' },
|
||||||
|
borderColor: { xs: 'divider', sm: 'transparent' },
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
|
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
|
||||||
const active = segment === activeSub;
|
const active = segment === activeSub;
|
||||||
@@ -220,6 +307,7 @@ class AppSubNav extends PureComponent {
|
|||||||
<ButtonBase
|
<ButtonBase
|
||||||
key={segment || 'index'}
|
key={segment || 'index'}
|
||||||
type="button"
|
type="button"
|
||||||
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
disableRipple
|
disableRipple
|
||||||
@@ -244,15 +332,17 @@ class AppSubNav extends PureComponent {
|
|||||||
navigate(segment ? `${activeNav}/${segment}` : activeNav);
|
navigate(segment ? `${activeNav}/${segment}` : activeNav);
|
||||||
}}
|
}}
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
width: SUB_NAV_WIDTH,
|
width: { xs: 'auto', sm: 72, md: SUB_NAV_WIDTH },
|
||||||
py: 1,
|
py: { xs: 0.5, sm: 1 },
|
||||||
px: 0.5,
|
px: { xs: active ? 1.25 : 0.75, sm: 0.5 },
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: { xs: 'row', sm: 'column' },
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 0.5,
|
gap: { xs: active ? 0.75 : 0, sm: 0.5 },
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
color: active
|
color: active
|
||||||
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
|
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
|
||||||
: theme.palette.text.secondary,
|
: theme.palette.text.secondary,
|
||||||
@@ -268,18 +358,19 @@ class AppSubNav extends PureComponent {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HelpBadge title={title || label} help={help} placement="right">
|
<HelpBadge title={title || label} help={help} placement="right">
|
||||||
<Icon sx={{ fontSize: 32 }} />
|
<Icon sx={{ fontSize: { xs: 20, sm: 28, md: 32 } }} />
|
||||||
</HelpBadge>
|
</HelpBadge>
|
||||||
{label ? (
|
{label ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
noWrap
|
noWrap
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.68rem',
|
fontSize: { xs: '0.75rem', sm: '0.68rem' },
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
display: active ? 'block' : { xs: 'none', sm: 'block' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -300,30 +391,60 @@ export default function AppShell() {
|
|||||||
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
|
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: 'var(--df-vh, 100vh)', bgcolor: 'background.default' }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', lg: 'row' },
|
||||||
|
minHeight: 'var(--df-vh, 100vh)',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AppSidebar activeNav={activeNav} navigate={navigate} />
|
<AppSidebar activeNav={activeNav} navigate={navigate} />
|
||||||
<AppSubNav
|
|
||||||
activeNav={activeNav}
|
|
||||||
activeSub={activeSub}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
sx={{
|
||||||
sx={(theme) => ({
|
|
||||||
flexGrow: 1,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
py: 4,
|
flexGrow: 1,
|
||||||
pr: 3,
|
|
||||||
bgcolor: theme.nav.mainBackground,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
})}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<AppSubNav
|
||||||
|
activeNav={activeNav}
|
||||||
|
activeSub={activeSub}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={(theme) => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
py: { xs: 2, md: 3, lg: 4 },
|
||||||
|
pr: { xs: 1.5, sm: 2, lg: 3 },
|
||||||
|
pl: { xs: 1.5, sm: 2, lg: 0 },
|
||||||
|
bgcolor: theme.nav.mainBackground,
|
||||||
|
minWidth: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<ChatFab />
|
|
||||||
<ChatDrawer />
|
<ChatDrawer />
|
||||||
<PageMinimap />
|
<PinnedAppletFabs pathname={pathname} />
|
||||||
|
<PinnedAppletDialog />
|
||||||
|
<PwaUpdateDialog />
|
||||||
|
{ChunkCacheMonitor ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ChunkCacheMonitor />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{BreakpointOverlay && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BreakpointOverlay />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
import authAutomat, { is502BadGateway } from '../automats/authAutomat';
|
||||||
|
|
||||||
let sessionBootstrapped = false;
|
let sessionBootstrapped = false;
|
||||||
|
|
||||||
@@ -10,15 +10,16 @@ class AuthBootstrap extends Component {
|
|||||||
if (sessionBootstrapped) return;
|
if (sessionBootstrapped) return;
|
||||||
sessionBootstrapped = true;
|
sessionBootstrapped = true;
|
||||||
|
|
||||||
bootstrapAuthMe()
|
authAutomat.actions.bootstrapSession()
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
if (session.forbidden) {
|
if (session?.forbidden) {
|
||||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||||
}
|
}
|
||||||
return authAutomat.actions.bootstrapSession(session);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
notifyError(err);
|
if (!is502BadGateway(err)) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
src/components/BreakpointOverlay.jsx
Normal file
72
src/components/BreakpointOverlay.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
export default function BreakpointOverlay() {
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const breakpoints = useMemo(() => {
|
||||||
|
const values = theme?.breakpoints?.values || {};
|
||||||
|
return [
|
||||||
|
{ name: 'xs', width: values.xs || 360, color: 'rgba(239, 68, 68, 0.75)' },
|
||||||
|
{ name: 'sm', width: values.sm || 600, color: 'rgba(245, 158, 11, 0.75)' },
|
||||||
|
{ name: 'md', width: values.md || 900, color: 'rgba(16, 185, 129, 0.75)' },
|
||||||
|
{ name: 'lg', width: values.lg || 1200, color: 'rgba(14, 165, 233, 0.75)' },
|
||||||
|
{ name: 'xl', width: values.xl || 1536, color: 'rgba(168, 85, 247, 0.75)' },
|
||||||
|
];
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 99999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{breakpoints.map((bp) => (
|
||||||
|
<Box
|
||||||
|
key={bp.name}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: bp.width,
|
||||||
|
border: `1px solid ${bp.color}`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
px: 0.6,
|
||||||
|
py: 0.2,
|
||||||
|
bgcolor: bp.color,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineHeight: 1,
|
||||||
|
borderBottomLeftRadius: 3,
|
||||||
|
userSelect: 'none',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bp.name}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
@@ -32,11 +32,12 @@ import PeopleIcon from '@mui/icons-material/People';
|
|||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||||
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
|
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import { company, number as numberApi } from '../api';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
|
||||||
import companyAutomat from '../automats/companyAutomat';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
import numberAutomat from '../automats/numberAutomat';
|
|
||||||
import {
|
import {
|
||||||
defaultIso639,
|
defaultIso639,
|
||||||
ISO639_OPTIONS,
|
ISO639_OPTIONS,
|
||||||
@@ -45,22 +46,30 @@ import {
|
|||||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
|
|
||||||
const NUMBER_SCHEMES = [
|
const NUMBER_SCHEMES = [
|
||||||
{ type: 'sku', label: 'Artikel-SKU', icon: Inventory2Icon },
|
{ type: 'sku', label: 'Artikelnummer (ArtNr)', icon: Inventory2Icon },
|
||||||
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
||||||
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
||||||
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
||||||
|
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
|
||||||
|
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
|
||||||
|
{ type: 'return', label: 'Rücksendung (Lieferant)', icon: AssignmentReturnIcon },
|
||||||
|
{ type: 'retoure', label: 'Retoure (Kunde)', icon: AssignmentReturnIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'standard',
|
id: 'standard',
|
||||||
label: 'Standard',
|
label: 'Standard',
|
||||||
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-)',
|
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-, RTN-, RET-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
||||||
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
||||||
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
||||||
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
||||||
|
quote: { prefix: 'ANG-', padding: 6, next: 1 },
|
||||||
|
order: { prefix: 'AUF-', padding: 6, next: 1 },
|
||||||
|
return: { prefix: 'RTN-', padding: 6, next: 1 },
|
||||||
|
retoure: { prefix: 'RET-', padding: 6, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,28 +81,40 @@ const PRESETS = [
|
|||||||
entity: { prefix: '', padding: 5, next: 20001 },
|
entity: { prefix: '', padding: 5, next: 20001 },
|
||||||
delivery: { prefix: '', padding: 5, next: 30001 },
|
delivery: { prefix: '', padding: 5, next: 30001 },
|
||||||
adjustment: { prefix: '', padding: 5, next: 90001 },
|
adjustment: { prefix: '', padding: 5, next: 90001 },
|
||||||
|
quote: { prefix: '', padding: 5, next: 40001 },
|
||||||
|
order: { prefix: '', padding: 5, next: 50001 },
|
||||||
|
return: { prefix: 'RTN', padding: 5, next: 1 },
|
||||||
|
retoure: { prefix: 'RET', padding: 5, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compact',
|
id: 'compact',
|
||||||
label: 'Kurzkürzel',
|
label: 'Kurzkürzel',
|
||||||
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-)',
|
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-, RTN-, RET-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'A-', padding: 5, next: 1 },
|
sku: { prefix: 'A-', padding: 5, next: 1 },
|
||||||
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
||||||
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
||||||
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
||||||
|
quote: { prefix: 'AG-', padding: 5, next: 1 },
|
||||||
|
order: { prefix: 'AU-', padding: 5, next: 1 },
|
||||||
|
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
||||||
|
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'international',
|
id: 'international',
|
||||||
label: 'International',
|
label: 'International',
|
||||||
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-)',
|
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-, RTN-, RET-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
||||||
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
||||||
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
||||||
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
||||||
|
quote: { prefix: 'Q-', padding: 5, next: 1 },
|
||||||
|
order: { prefix: 'ORD-', padding: 5, next: 1 },
|
||||||
|
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
||||||
|
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -105,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
|
|||||||
return `${prefix ?? ''}${numStr}`;
|
return `${prefix ?? ''}${numStr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CompanySetupDialog extends Component {
|
function mapCompanyState(state) {
|
||||||
|
return {
|
||||||
|
companies: Array.isArray(state?.companies) ? state.companies : [],
|
||||||
|
loading: state?.status === 'idle' || state?.status === 'loading',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompanySetupDialog extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
companies: [],
|
...mapCompanyState(companyAutomat.state),
|
||||||
loading: true,
|
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -129,26 +156,25 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
void this.loadCompanies();
|
this.unsubscribe = companyAutomat.subscribe(this, mapCompanyState);
|
||||||
|
this.unsubscribeAuth = authAutomat.subscribe(this, (state) => ({
|
||||||
|
selectedCompanyId: state.user?.selectedCompanyId ?? null,
|
||||||
|
}));
|
||||||
|
this.setState(mapCompanyState(companyAutomat.state));
|
||||||
|
if (companyAutomat.state.status !== 'succeeded') {
|
||||||
|
void companyAutomat.actions.load().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCompanies() {
|
componentWillUnmount() {
|
||||||
this.setState({ loading: true });
|
this.unsubscribe?.();
|
||||||
try {
|
this.unsubscribeAuth?.();
|
||||||
const data = await company.list();
|
|
||||||
this.setState({ companies: data.companies ?? [] });
|
|
||||||
} catch {
|
|
||||||
this.setState({ companies: [] });
|
|
||||||
} finally {
|
|
||||||
this.setState({ loading: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSelect(companyId) {
|
async handleSelect(companyId) {
|
||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
await authAutomat.actions.selectCompany(companyId);
|
await authAutomat.actions.selectCompany(companyId);
|
||||||
await bootstrapAuthMe();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -201,30 +227,8 @@ class CompanySetupDialog extends Component {
|
|||||||
iso639,
|
iso639,
|
||||||
taxrate: parsedTaxrate,
|
taxrate: parsedTaxrate,
|
||||||
reducedtaxrate: parsedReducedtaxrate,
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
|
numbers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply initial number sequences if set
|
|
||||||
if (numbers) {
|
|
||||||
try {
|
|
||||||
const listRes = await numberApi.list();
|
|
||||||
const serverNumbers = listRes?.numbers ?? [];
|
|
||||||
for (const serverRow of serverNumbers) {
|
|
||||||
const userConfig = numbers[serverRow.type];
|
|
||||||
if (userConfig) {
|
|
||||||
const prefix = String(userConfig.prefix ?? '');
|
|
||||||
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
|
||||||
const next = Math.max(1, Number(userConfig.next) || 1);
|
|
||||||
await numberApi.update(serverRow.id, { prefix, padding, next });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (numErr) {
|
|
||||||
console.warn('Initial number sequences could not be saved:', numErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await bootstrapAuthMe();
|
|
||||||
await companyAutomat.actions.load();
|
|
||||||
await numberAutomat.actions.reload().catch(() => {});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -235,7 +239,6 @@ class CompanySetupDialog extends Component {
|
|||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
try {
|
try {
|
||||||
await authAutomat.actions.logout();
|
await authAutomat.actions.logout();
|
||||||
await bootstrapAuthMe();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
}
|
}
|
||||||
@@ -246,6 +249,10 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
if (authAutomat.state.user?.selectedCompanyId != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
companies,
|
companies,
|
||||||
loading,
|
loading,
|
||||||
@@ -274,22 +281,7 @@ class CompanySetupDialog extends Component {
|
|||||||
maxWidth={hasCompanies ? 'sm' : 'md'}
|
maxWidth={hasCompanies ? 'sm' : 'md'}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ pb: 1 }}>
|
|
||||||
{loading
|
|
||||||
? 'Unternehmen'
|
|
||||||
: hasCompanies
|
|
||||||
? 'Unternehmen auswählen'
|
|
||||||
: 'Willkommen bei Dash'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!loading ? (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
|
|
||||||
{hasCompanies
|
|
||||||
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
|
|
||||||
: 'Legen Sie Ihr erstes Unternehmen an und wählen Sie das Format für Ihre Nummernkreise.'}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
isBackendFailure(error) ? (
|
isBackendFailure(error) ? (
|
||||||
<BackendError error={error} sx={{ mb: 2 }} />
|
<BackendError error={error} sx={{ mb: 2 }} />
|
||||||
@@ -398,13 +390,6 @@ class CompanySetupDialog extends Component {
|
|||||||
|
|
||||||
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
||||||
<Box sx={{ pt: 1 }}>
|
<Box sx={{ pt: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Nummernkreise & Belegnummern
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
|
||||||
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
|
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
|
||||||
{PRESETS.map((p) => {
|
{PRESETS.map((p) => {
|
||||||
const isSelected = selectedPreset === p.id;
|
const isSelected = selectedPreset === p.id;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Paper from '@mui/material/Paper';
|
|||||||
import Popper from '@mui/material/Popper';
|
import Popper from '@mui/material/Popper';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { helpAutomat } from '../automats';
|
import helpAutomat from '../automats/helpAutomat';
|
||||||
import {
|
import {
|
||||||
RED,
|
RED,
|
||||||
RED_HOVER,
|
RED_HOVER,
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class LoginPage extends PureComponent {
|
|||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
await authAutomat.actions.loginWithGoogle(idToken);
|
await authAutomat.actions.loginWithGoogle(idToken);
|
||||||
await bootstrapAuthMe();
|
await bootstrapAuthMe();
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const returnTo = params.get('returnTo');
|
||||||
|
if (returnTo) {
|
||||||
|
window.location.replace(returnTo);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GoogleSignInCancelledError) return;
|
if (err instanceof GoogleSignInCancelledError) return;
|
||||||
logError('[login] Google sign-in failed', {
|
logError('[login] Google sign-in failed', {
|
||||||
|
|||||||
318
src/components/OnlinePrinterIcons.jsx
Normal file
318
src/components/OnlinePrinterIcons.jsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||||
|
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import { notifyError } from '../utils/notify';
|
||||||
|
import { getPrinterImage } from '../assets/printers';
|
||||||
|
|
||||||
|
export default class OnlinePrinterIcons extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
|
||||||
|
? companyAutomat.state.agentprinters
|
||||||
|
: [],
|
||||||
|
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||||
|
? companyAutomat.state.livePrinters
|
||||||
|
: [],
|
||||||
|
menuAnchor: null,
|
||||||
|
};
|
||||||
|
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||||
|
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||||
|
this.handleTestPrint = this.handleTestPrint.bind(this);
|
||||||
|
this.handleCancelAllJobs = this.handleCancelAllJobs.bind(this);
|
||||||
|
this.handleOpenPrinterSettings = this.handleOpenPrinterSettings.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
|
||||||
|
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOnlinePrinters() {
|
||||||
|
const { agentprinters = [], livePrinters = [] } = this.state;
|
||||||
|
const liveBySerial = new Map();
|
||||||
|
for (const lp of livePrinters) {
|
||||||
|
if (lp.serial) liveBySerial.set(String(lp.serial).toLowerCase(), lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentprinters.length > 0) {
|
||||||
|
return agentprinters
|
||||||
|
.filter((p) => {
|
||||||
|
if (p.online === true) return true;
|
||||||
|
const live =
|
||||||
|
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||||
|
livePrinters.find(
|
||||||
|
(lp) =>
|
||||||
|
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||||
|
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||||
|
);
|
||||||
|
if (!live) return false;
|
||||||
|
return live.status !== 'offline' && !live.unreachable && !live.errors?.unreachable;
|
||||||
|
})
|
||||||
|
.map((p) => {
|
||||||
|
const live =
|
||||||
|
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||||
|
livePrinters.find(
|
||||||
|
(lp) =>
|
||||||
|
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||||
|
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||||
|
);
|
||||||
|
return { ...p, live };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return livePrinters
|
||||||
|
.filter((lp) => lp.status !== 'offline' && !lp.unreachable && !lp.errors?.unreachable)
|
||||||
|
.map((lp) => ({
|
||||||
|
id: lp.agentprinterId || lp.id || lp.serial,
|
||||||
|
model: lp.model,
|
||||||
|
serial: lp.serial,
|
||||||
|
type: lp.type,
|
||||||
|
online: true,
|
||||||
|
open: Boolean(lp.errors?.headOpen || lp.headOpen),
|
||||||
|
pause: Boolean(lp.errors?.pause || lp.pause),
|
||||||
|
error: Boolean(lp.errors?.other || lp.error),
|
||||||
|
labelLength: lp.labelLength,
|
||||||
|
live: lp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenMenu(event, printer) {
|
||||||
|
this.setState({
|
||||||
|
menuAnchor: {
|
||||||
|
el: event.currentTarget,
|
||||||
|
printer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseMenu() {
|
||||||
|
this.setState({ menuAnchor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTestPrint(printer) {
|
||||||
|
if (!printer?.id) return;
|
||||||
|
this.handleCloseMenu();
|
||||||
|
companyAutomat.actions
|
||||||
|
.sendTestPrint(printer.id, {
|
||||||
|
model: printer.model || printer.type || 'Zebra',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Warteschlange gesendet.`,
|
||||||
|
{ variant: 'success' },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelAllJobs(printer) {
|
||||||
|
if (!printer?.id) return;
|
||||||
|
this.handleCloseMenu();
|
||||||
|
companyAutomat.actions
|
||||||
|
.cancelAllPrinterJobs(printer.id)
|
||||||
|
.then(() => {
|
||||||
|
enqueueSnackbar(`Druckaufträge für ${printer.model || 'Drucker'} abgebrochen.`, {
|
||||||
|
variant: 'info',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenPrinterSettings() {
|
||||||
|
this.handleCloseMenu();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('open-settings', { detail: { tab: 'printeragent' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { menuAnchor } = this.state;
|
||||||
|
const onlinePrinters = this.getOnlinePrinters();
|
||||||
|
|
||||||
|
if (onlinePrinters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePrinter = menuAnchor?.printer;
|
||||||
|
const activeHasError = Boolean(
|
||||||
|
activePrinter?.error ||
|
||||||
|
activePrinter?.open ||
|
||||||
|
activePrinter?.live?.errors?.other ||
|
||||||
|
activePrinter?.live?.errors?.headOpen,
|
||||||
|
);
|
||||||
|
const activeIsPaused = Boolean(activePrinter?.pause || activePrinter?.live?.errors?.pause);
|
||||||
|
const activeStatusText = activeHasError
|
||||||
|
? activePrinter?.open || activePrinter?.live?.errors?.headOpen
|
||||||
|
? 'Druckkopf offen'
|
||||||
|
: 'Fehler'
|
||||||
|
: activeIsPaused
|
||||||
|
? 'Pausiert'
|
||||||
|
: 'Bereit';
|
||||||
|
|
||||||
|
const anchorEl = menuAnchor?.el;
|
||||||
|
const isTop = Boolean(
|
||||||
|
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'row', lg: 'column' },
|
||||||
|
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||||
|
alignSelf: { xs: 'center', lg: 'flex-start' },
|
||||||
|
px: { xs: 0.5, sm: 1, lg: 1.5 },
|
||||||
|
py: { xs: 0.5, lg: 0.5 },
|
||||||
|
gap: { xs: 0.5, lg: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onlinePrinters.map((p, index) => {
|
||||||
|
const hasError = Boolean(
|
||||||
|
p.error || p.open || p.live?.errors?.other || p.live?.errors?.headOpen,
|
||||||
|
);
|
||||||
|
const isPaused = Boolean(p.pause || p.live?.errors?.pause);
|
||||||
|
const label = p.model || 'Drucker';
|
||||||
|
const statusText = hasError
|
||||||
|
? p.open || p.live?.errors?.headOpen
|
||||||
|
? 'Druckkopf offen'
|
||||||
|
: 'Fehler'
|
||||||
|
: isPaused
|
||||||
|
? 'Pausiert'
|
||||||
|
: 'Bereit';
|
||||||
|
const tooltipTitle = `${label}${p.serial ? ` (${p.serial})` : ''} • ${statusText}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={p.id || p.serial || index}
|
||||||
|
onClick={(e) => this.handleOpenMenu(e, p)}
|
||||||
|
title={tooltipTitle}
|
||||||
|
sx={(theme) => ({
|
||||||
|
p: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
color: theme.nav?.item || 'inherit',
|
||||||
|
'&:hover': {
|
||||||
|
color: theme.nav?.itemHoverText || 'inherit',
|
||||||
|
bgcolor: theme.nav?.itemHover || 'action.hover',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
aria-label={`Drucker ${label}`}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="dot"
|
||||||
|
color={hasError ? 'error' : isPaused ? 'warning' : 'success'}
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<PrintOutlinedIcon sx={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={this.handleCloseMenu}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: isTop ? 'bottom' : 'top',
|
||||||
|
horizontal: isTop ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: isTop ? 'top' : 'bottom',
|
||||||
|
horizontal: isTop ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activePrinter && (
|
||||||
|
<Box sx={{ px: 2, py: 1, minWidth: 210 }}>
|
||||||
|
{getPrinterImage(activePrinter.model) ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={getPrinterImage(activePrinter.model)}
|
||||||
|
alt={activePrinter.model}
|
||||||
|
sx={{ display: 'block', mx: 'auto', mb: 1, maxWidth: 80, height: 'auto' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} noWrap>
|
||||||
|
{activePrinter.model || 'Drucker'}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={activeStatusText}
|
||||||
|
size="small"
|
||||||
|
color={activeHasError ? 'error' : activeIsPaused ? 'warning' : 'success'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{activePrinter.serial ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontFamily: 'monospace', display: 'block' }}
|
||||||
|
>
|
||||||
|
S/N: {activePrinter.serial}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{activePrinter.labelLength ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Etikett: {activePrinter.labelLength} px
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<MenuItem onClick={() => this.handleTestPrint(activePrinter)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PrintOutlinedIcon fontSize="small" color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Testdruck"
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={() => this.handleCancelAllJobs(activePrinter)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Aufträge abbrechen"
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={this.handleOpenPrinterSettings}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Einstellungen" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { matchPath, useLocation } from 'react-router-dom';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Portal from '@mui/material/Portal';
|
||||||
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
|
|
||||||
|
|
||||||
const MINIMAP_WIDTH = 220;
|
const MINIMAP_WIDTH = 220;
|
||||||
const MINIMAP_MAX_HEIGHT = 300;
|
const MINIMAP_MAX_HEIGHT = 300;
|
||||||
@@ -42,16 +41,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
|
|||||||
const idleTimerRef = useRef(null);
|
const idleTimerRef = useRef(null);
|
||||||
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
|
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
|
||||||
|
|
||||||
// SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
|
|
||||||
// default tab resolves to seo.
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
|
|
||||||
const isSeoPage = Boolean(
|
|
||||||
articleMatch &&
|
|
||||||
(articleMatch.params?.tab === ARTICLE_TAB.seo ||
|
|
||||||
(!articleMatch.params?.tab &&
|
|
||||||
readPreferredArticleTab() === ARTICLE_TAB.seo)),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Move the viewport rectangle without re-rendering (called on scroll). */
|
/** Move the viewport rectangle without re-rendering (called on scroll). */
|
||||||
const updateViewport = useCallback(() => {
|
const updateViewport = useCallback(() => {
|
||||||
@@ -160,11 +149,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
|
|||||||
if (enabled && open) rebuild();
|
if (enabled && open) rebuild();
|
||||||
}, [enabled, open, rebuild]);
|
}, [enabled, open, rebuild]);
|
||||||
|
|
||||||
// Reset scroll visibility on navigation; the new page starts hidden
|
|
||||||
// until the user scrolls again.
|
|
||||||
useEffect(() => {
|
|
||||||
setScrollActive(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rebuild();
|
rebuild();
|
||||||
@@ -214,23 +198,24 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
|
|||||||
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
|
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
|
||||||
const visible = scrollActive || hovering || dragging;
|
const visible = scrollActive || hovering || dragging;
|
||||||
|
|
||||||
if (!enabled || !isSeoPage) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
// Collapsed: a single centered pill to reopen the overview.
|
// Collapsed: a single centered pill to reopen the overview.
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Portal>
|
||||||
sx={{
|
<Box
|
||||||
position: 'fixed',
|
sx={{
|
||||||
left: '50%',
|
position: 'fixed',
|
||||||
bottom: 16,
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
bottom: 16,
|
||||||
zIndex: 1300,
|
transform: 'translateX(-50%)',
|
||||||
opacity: visible ? 1 : 0,
|
zIndex: 1300,
|
||||||
transition: 'opacity 300ms ease',
|
opacity: visible ? 1 : 0,
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
transition: 'opacity 300ms ease',
|
||||||
}}
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
>
|
}}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -247,12 +232,14 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
|
|||||||
>
|
>
|
||||||
<MapRoundedIcon fontSize="small" />
|
<MapRoundedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Portal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Portal>
|
||||||
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
@@ -383,5 +370,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
</Portal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
867
src/components/PicturesDialog.jsx
Normal file
867
src/components/PicturesDialog.jsx
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
// #region imports
|
||||||
|
import { PureComponent, createRef } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import AppDialog from './AppDialog';
|
||||||
|
import { thumb, article as articleApi } from '../api';
|
||||||
|
import artikelAutomat from '../automats/artikelAutomat';
|
||||||
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import categoryAutomat from '../automats/categoryAutomat';
|
||||||
|
import chatAutomat from '../automats/chatAutomat';
|
||||||
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
|
import {
|
||||||
|
articleWithPhotoOrder,
|
||||||
|
articleWithUploadedPhoto,
|
||||||
|
articleWithoutPhoto,
|
||||||
|
buildGoogleSearchQuery,
|
||||||
|
formatPhotoDimensions,
|
||||||
|
formatPhotoSizeKb,
|
||||||
|
sortedPhotos,
|
||||||
|
} from '../utils/articleDetail';
|
||||||
|
import { notifyError } from '../utils/notify';
|
||||||
|
import { readClipboardImage } from '../utils/pasteImage';
|
||||||
|
import { prepareArticlePhoto } from '../utils/prepareArticlePhoto';
|
||||||
|
import ThumbHashImage from './article/ThumbHashImage';
|
||||||
|
import ArticlePhoto from './article/ArticlePhoto';
|
||||||
|
import BrandImageSearchDialog from './brand/BrandImageSearchDialog';
|
||||||
|
import ArticleImageSearchDialog from './article/ArticleImageSearchDialog';
|
||||||
|
import {
|
||||||
|
CHECKERBOARD,
|
||||||
|
SCRIM_BUTTON,
|
||||||
|
SCRIM_LABEL,
|
||||||
|
SCRIM_SOFT,
|
||||||
|
SCRIM_STRONG,
|
||||||
|
SHADOW_THUMB_RING,
|
||||||
|
VEIL_MUTED,
|
||||||
|
WHITE,
|
||||||
|
} from '../colors';
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region constants & styles
|
||||||
|
/** Max edge (px) of the single-image preview inside the dialog. */
|
||||||
|
const MAX_PREVIEW = 240;
|
||||||
|
|
||||||
|
const overlayChipSx = {
|
||||||
|
height: 22,
|
||||||
|
bgcolor: SCRIM_LABEL,
|
||||||
|
color: WHITE,
|
||||||
|
'& .MuiChip-label': { px: 0.75, fontSize: 11 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayIconSx = {
|
||||||
|
color: WHITE,
|
||||||
|
bgcolor: SCRIM_BUTTON,
|
||||||
|
'&:hover': { bgcolor: SCRIM_STRONG },
|
||||||
|
'&.Mui-disabled': { color: VEIL_MUTED, bgcolor: SCRIM_SOFT },
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region helper functions
|
||||||
|
function isSingleMode(props) {
|
||||||
|
if (typeof props.single === 'boolean') {
|
||||||
|
return props.single;
|
||||||
|
}
|
||||||
|
if (props.maxPictures === 1 || props.maxPhotos === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (props.brand || props.category || props.attributeValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (props.article) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePictures(props, single) {
|
||||||
|
if (Array.isArray(props.pictures)) {
|
||||||
|
return single ? props.pictures.slice(0, 1) : props.pictures;
|
||||||
|
}
|
||||||
|
if (Array.isArray(props.pics)) {
|
||||||
|
return single ? props.pics.slice(0, 1) : props.pics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brands: only logo[0] with sort=0 fillable
|
||||||
|
if (props.brand) {
|
||||||
|
const rawPics = Array.isArray(props.brand.pics) ? props.brand.pics : [];
|
||||||
|
const firstPic = rawPics[0] || (props.brand.fullHash ? {
|
||||||
|
fullHash: props.brand.fullHash,
|
||||||
|
thumbHash: props.brand.thumbHash,
|
||||||
|
thumbW: props.brand.thumbW,
|
||||||
|
thumbH: props.brand.thumbH,
|
||||||
|
sort: 0,
|
||||||
|
} : null);
|
||||||
|
return firstPic ? [firstPic] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles: multiple photos allowed
|
||||||
|
if (props.article) {
|
||||||
|
const list = sortedPhotos(props.article);
|
||||||
|
return single ? list.slice(0, 1) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories: single photo
|
||||||
|
if (props.category) {
|
||||||
|
if (Array.isArray(props.category.pics) && props.category.pics.length > 0) {
|
||||||
|
return [props.category.pics[0]];
|
||||||
|
}
|
||||||
|
if (props.category.fullHash) {
|
||||||
|
return [{
|
||||||
|
fullHash: props.category.fullHash,
|
||||||
|
thumbHash: props.category.thumbHash,
|
||||||
|
thumbW: props.category.thumbW,
|
||||||
|
thumbH: props.category.thumbH,
|
||||||
|
sort: 0,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute values: single photo
|
||||||
|
if (props.attributeValue) {
|
||||||
|
if (Array.isArray(props.attributeValue.pics) && props.attributeValue.pics.length > 0) {
|
||||||
|
return [props.attributeValue.pics[0]];
|
||||||
|
}
|
||||||
|
if (props.attributeValue.fullHash) {
|
||||||
|
return [{
|
||||||
|
fullHash: props.attributeValue.fullHash,
|
||||||
|
thumbHash: props.attributeValue.thumbHash,
|
||||||
|
thumbW: props.attributeValue.thumbW,
|
||||||
|
thumbH: props.attributeValue.thumbH,
|
||||||
|
sort: 0,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.pic) {
|
||||||
|
return [props.pic];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTitle(props, single) {
|
||||||
|
if (props.title) {
|
||||||
|
return typeof props.title === 'function' ? props.title(props) : props.title;
|
||||||
|
}
|
||||||
|
if (props.brand) {
|
||||||
|
const name = props.brand.name ? ` ${props.brand.name}` : '';
|
||||||
|
return `Logo:${name}`;
|
||||||
|
}
|
||||||
|
if (props.category) {
|
||||||
|
const name = props.category.name ? ` ${props.category.name}` : '';
|
||||||
|
return `Kategoriebild:${name}`;
|
||||||
|
}
|
||||||
|
if (props.attributeValue) {
|
||||||
|
const name = props.attributeValue.value || props.attributeValue.name
|
||||||
|
? ` ${props.attributeValue.value || props.attributeValue.name}`
|
||||||
|
: '';
|
||||||
|
return `Bild:${name}`;
|
||||||
|
}
|
||||||
|
if (props.article) {
|
||||||
|
const name = props.article.name || props.article.sku
|
||||||
|
? ` ${props.article.name || props.article.sku}`
|
||||||
|
: '';
|
||||||
|
return `Bilder:${name}`;
|
||||||
|
}
|
||||||
|
return single ? 'Bild' : 'Bilder';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGoogleImagesQuery(props) {
|
||||||
|
if (props.article) {
|
||||||
|
return buildGoogleSearchQuery(props.article);
|
||||||
|
}
|
||||||
|
if (props.brand?.name) {
|
||||||
|
return String(props.brand.name).trim();
|
||||||
|
}
|
||||||
|
if (props.category?.name) {
|
||||||
|
return String(props.category.name).trim();
|
||||||
|
}
|
||||||
|
if (props.attributeValue) {
|
||||||
|
return String(props.attributeValue.value || props.attributeValue.name || '').trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified class component for entity pictures and logos.
|
||||||
|
* Supports:
|
||||||
|
* - Single picture mode (Brand logo with sort=0, Category, AttributeValue)
|
||||||
|
* - Multi picture mode (Article photos with reorder & chat edit)
|
||||||
|
* - Actions: Löschen, Neues Suchen, Bild einfügen (clipboard), Bild hochladen (file), Schließen.
|
||||||
|
*/
|
||||||
|
// #region PicturesDialog
|
||||||
|
export default class PicturesDialog extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.fileInputRef = createRef();
|
||||||
|
this.state = {
|
||||||
|
busy: false,
|
||||||
|
searchOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region dialog close
|
||||||
|
handleClose = () => {
|
||||||
|
if (this.state.busy) return;
|
||||||
|
this.props.onClose?.();
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region upload & paste operations
|
||||||
|
// MARK: prepares image and calls upload handler or automat action
|
||||||
|
uploadRawImage = async (rawBlob) => {
|
||||||
|
const { onUpload, brand, article, category, onArticleUpdated } = this.props;
|
||||||
|
this.setState({ busy: true });
|
||||||
|
try {
|
||||||
|
const file = await prepareArticlePhoto(rawBlob);
|
||||||
|
|
||||||
|
if (onUpload) {
|
||||||
|
await onUpload(file);
|
||||||
|
} else if (brand?.id) {
|
||||||
|
await companyAutomat.actions.addBrandImage(brand.id, file);
|
||||||
|
enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' });
|
||||||
|
} else if (article?.sku) {
|
||||||
|
const uploadStarted = performance.now();
|
||||||
|
const res = await articleApi.uploadImage(article.sku, file);
|
||||||
|
console.log('[article-photo] upload', {
|
||||||
|
uploadMs: Math.round(performance.now() - uploadStarted),
|
||||||
|
bytes: file.size,
|
||||||
|
sku: article.sku,
|
||||||
|
});
|
||||||
|
requireObject(res, 'POST /api/article/:sku/image');
|
||||||
|
requireObject(res.image, 'image');
|
||||||
|
const updated = articleWithUploadedPhoto(article, res.image);
|
||||||
|
artikelAutomat.actions.setArticle(updated);
|
||||||
|
onArticleUpdated?.(updated);
|
||||||
|
enqueueSnackbar('Artikelbild gespeichert.', { variant: 'success' });
|
||||||
|
} else if (category?.id) {
|
||||||
|
await categoryAutomat.actions.uploadCategoryImage({
|
||||||
|
categoryId: category.id,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
enqueueSnackbar('Kategoriebild gespeichert.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ busy: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// MARK: reads clipboard image and uploads
|
||||||
|
handlePasteClick = async () => {
|
||||||
|
if (this.state.busy || this.props.disabled) return;
|
||||||
|
try {
|
||||||
|
const image = await readClipboardImage();
|
||||||
|
if (image) {
|
||||||
|
await this.uploadRawImage(image);
|
||||||
|
} else {
|
||||||
|
enqueueSnackbar('Kein Bild in der Zwischenablage gefunden.', { variant: 'info' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Clipboard read error:', err);
|
||||||
|
enqueueSnackbar('Zugriff auf Zwischenablage nicht möglich.', { variant: 'warning' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUploadClick = () => {
|
||||||
|
if (this.state.busy || this.props.disabled) return;
|
||||||
|
this.fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFileChange = (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
void this.uploadRawImage(file);
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region deletion operations
|
||||||
|
// MARK: deletes single image (brand, category, attributeValue)
|
||||||
|
handleDeleteSingle = async () => {
|
||||||
|
const { busy } = this.state;
|
||||||
|
const { disabled, brand, category, attributeValue, onDelete } = this.props;
|
||||||
|
if (busy || disabled) return;
|
||||||
|
|
||||||
|
const single = isSingleMode(this.props);
|
||||||
|
const pics = resolvePictures(this.props, single);
|
||||||
|
const pic = pics[0];
|
||||||
|
if (!pic) return;
|
||||||
|
|
||||||
|
const targetName = brand?.name || category?.name || attributeValue?.name || 'dieses Bild';
|
||||||
|
const label = brand ? `Logo von „${targetName}"` : `Bild von „${targetName}"`;
|
||||||
|
if (!window.confirm(`${label} wirklich löschen?`)) return;
|
||||||
|
|
||||||
|
this.setState({ busy: true });
|
||||||
|
try {
|
||||||
|
if (onDelete) {
|
||||||
|
await onDelete(pic, 0);
|
||||||
|
} else if (brand?.id) {
|
||||||
|
await companyAutomat.actions.deleteBrandImage(brand.id);
|
||||||
|
enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' });
|
||||||
|
} else if (category?.id) {
|
||||||
|
await categoryAutomat.actions.deleteCategoryImage(category.id);
|
||||||
|
enqueueSnackbar('Kategoriebild gelöscht.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ busy: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// MARK: deletes multi image (article photos)
|
||||||
|
handleDeleteMulti = async (fullHash) => {
|
||||||
|
const { busy } = this.state;
|
||||||
|
const { disabled, article, onDelete, onArticleUpdated } = this.props;
|
||||||
|
if (busy || disabled) return;
|
||||||
|
|
||||||
|
this.setState({ busy: true });
|
||||||
|
try {
|
||||||
|
if (onDelete) {
|
||||||
|
await onDelete(fullHash);
|
||||||
|
} else if (article?.sku) {
|
||||||
|
await articleApi.deleteImage(article.sku, fullHash);
|
||||||
|
const updated = articleWithoutPhoto(article, fullHash);
|
||||||
|
artikelAutomat.actions.setArticle(updated);
|
||||||
|
onArticleUpdated?.(updated);
|
||||||
|
enqueueSnackbar('Artikelbild gelöscht.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ busy: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region reordering & chat edit (multi mode)
|
||||||
|
// MARK: calls orderImages via articleApi
|
||||||
|
handleMoveMulti = async (fullHash, delta) => {
|
||||||
|
const { busy } = this.state;
|
||||||
|
const { disabled, article, onReorder, onArticleUpdated } = this.props;
|
||||||
|
if (busy || disabled) return;
|
||||||
|
|
||||||
|
const photos = sortedPhotos(article);
|
||||||
|
const index = photos.findIndex((row) => row.fullHash === fullHash);
|
||||||
|
const target = index + delta;
|
||||||
|
if (index < 0 || target < 0 || target >= photos.length) return;
|
||||||
|
|
||||||
|
const next = [...photos];
|
||||||
|
const [row] = next.splice(index, 1);
|
||||||
|
next.splice(target, 0, row);
|
||||||
|
const fullHashes = next.map((item) => item.fullHash);
|
||||||
|
|
||||||
|
this.setState({ busy: true });
|
||||||
|
try {
|
||||||
|
if (onReorder) {
|
||||||
|
await onReorder(fullHashes);
|
||||||
|
} else if (article?.sku) {
|
||||||
|
const res = await articleApi.orderImages(article.sku, fullHashes);
|
||||||
|
requireObject(res, 'PUT /api/article/:sku/images/order');
|
||||||
|
requireArray(res.fullHashes, 'fullHashes');
|
||||||
|
const updated = articleWithPhotoOrder(article, res.fullHashes);
|
||||||
|
artikelAutomat.actions.setArticle(updated);
|
||||||
|
onArticleUpdated?.(updated);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ busy: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEditInChat = (photo, index) => {
|
||||||
|
const { article, onEditInChat } = this.props;
|
||||||
|
if (onEditInChat) {
|
||||||
|
onEditInChat(photo, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sku = article?.sku ?? '';
|
||||||
|
const imageNr = index + 1;
|
||||||
|
const prompt = `Artikelbild Nr ${imageNr} von Artikel SKU ${sku} folgende Änderung: `;
|
||||||
|
chatAutomat.actions.openWithPrompt(prompt);
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region search dialog handlers
|
||||||
|
handleSearchOpen = () => {
|
||||||
|
if (this.state.busy || this.props.disabled) return;
|
||||||
|
if (this.props.onSearchNew) {
|
||||||
|
this.props.onSearchNew();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ searchOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSearchClose = () => {
|
||||||
|
this.setState({ searchOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOpenGoogleImages = () => {
|
||||||
|
const query = resolveGoogleImagesQuery(this.props);
|
||||||
|
if (!query) return;
|
||||||
|
const url = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region render helpers
|
||||||
|
renderSinglePreview(pic, title) {
|
||||||
|
const { brand, emptyText } = this.props;
|
||||||
|
const hasPic = Boolean(pic?.fullHash || pic?.thumbHash);
|
||||||
|
const { fullHash, thumbHash, thumbW, thumbH, fullW, fullH, fullSize } = pic ?? {};
|
||||||
|
|
||||||
|
const tw = Number(thumbW || fullW);
|
||||||
|
const th = Number(thumbH || fullH);
|
||||||
|
let w = MAX_PREVIEW;
|
||||||
|
let h = MAX_PREVIEW;
|
||||||
|
if (Number.isFinite(tw) && Number.isFinite(th) && tw > 0 && th > 0) {
|
||||||
|
const scale = Math.min(1, MAX_PREVIEW / Math.max(tw, th));
|
||||||
|
w = Math.max(1, Math.round(tw * scale));
|
||||||
|
h = Math.max(1, Math.round(th * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDimensions = (fullW != null && fullH != null) || (thumbW != null && thumbH != null);
|
||||||
|
const hasSize = fullSize != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: WHITE,
|
||||||
|
backgroundImage: CHECKERBOARD,
|
||||||
|
backgroundSize: '16px 16px',
|
||||||
|
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
||||||
|
boxShadow: SHADOW_THUMB_RING,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thumbHash ? <ThumbHashImage thumbHash={thumbHash} /> : null}
|
||||||
|
{fullHash ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={thumb.url(fullHash)}
|
||||||
|
alt={title}
|
||||||
|
draggable={false}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{emptyText || (brand ? 'Kein Logo hinterlegt.' : 'Kein Bild hinterlegt.')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dimension and size overlay chip */}
|
||||||
|
{hasPic && (hasDimensions || hasSize) ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
left: 6,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasDimensions ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={formatPhotoDimensions(fullW ?? thumbW, fullH ?? thumbH)}
|
||||||
|
sx={overlayChipSx}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{hasSize ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={formatPhotoSizeKb(fullSize)}
|
||||||
|
sx={overlayChipSx}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMultiPhotoTile(photo, index, total) {
|
||||||
|
const { busy } = this.state;
|
||||||
|
const { disabled } = this.props;
|
||||||
|
const hasDimensions = photo.fullW != null && photo.fullH != null;
|
||||||
|
const hasSize = photo.fullSize != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={photo.fullHash || index}
|
||||||
|
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<ArticlePhoto photo={photo} size={200} sx={{ position: 'absolute', inset: 0 }} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
p: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasDimensions ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={formatPhotoDimensions(photo.fullW, photo.fullH)}
|
||||||
|
sx={overlayChipSx}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{hasSize ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={formatPhotoSizeKb(photo.fullSize)}
|
||||||
|
sx={overlayChipSx}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={() => this.handleEditInChat(photo, index)}
|
||||||
|
sx={overlayIconSx}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Nach vorne"
|
||||||
|
disabled={busy || disabled || index === 0}
|
||||||
|
onClick={() => void this.handleMoveMulti(photo.fullHash, -1)}
|
||||||
|
sx={overlayIconSx}
|
||||||
|
>
|
||||||
|
<KeyboardArrowUpIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Nach hinten"
|
||||||
|
disabled={busy || disabled || index >= total - 1}
|
||||||
|
onClick={() => void this.handleMoveMulti(photo.fullHash, 1)}
|
||||||
|
sx={overlayIconSx}
|
||||||
|
>
|
||||||
|
<KeyboardArrowDownIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Löschen"
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={() => void this.handleDeleteMulti(photo.fullHash)}
|
||||||
|
sx={overlayIconSx}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region main render
|
||||||
|
render() {
|
||||||
|
const { open, disabled, brand, article, searchDialog: CustomSearchDialog } = this.props;
|
||||||
|
const { busy, searchOpen } = this.state;
|
||||||
|
const single = isSingleMode(this.props);
|
||||||
|
const pics = resolvePictures(this.props, single);
|
||||||
|
const title = resolveTitle(this.props, single);
|
||||||
|
const hasPic = Boolean(pics[0]?.fullHash || pics[0]?.thumbHash);
|
||||||
|
const googleQuery = resolveGoogleImagesQuery(this.props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppDialog
|
||||||
|
open={open}
|
||||||
|
onClose={busy ? undefined : this.handleClose}
|
||||||
|
maxWidth={single ? false : 'md'}
|
||||||
|
fullWidth={!single}
|
||||||
|
aria-labelledby="entity-pictures-dialog-title"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: single
|
||||||
|
? { width: 360, maxWidth: '90vw' }
|
||||||
|
: { maxWidth: '95vw' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
id="entity-pictures-dialog-title"
|
||||||
|
sx={{ px: 2.5, pt: 2, pb: 0.5 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers={!single} sx={{ px: 2.5, py: single ? 1 : 1.5 }}>
|
||||||
|
{single ? (
|
||||||
|
this.renderSinglePreview(pics[0], title)
|
||||||
|
) : pics.length === 0 ? (
|
||||||
|
<Box sx={{ color: 'text.secondary', py: 2 }}>Keine Bilder.</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pics.map((photo, index) =>
|
||||||
|
this.renderMultiPhotoTile(photo, index, pics.length),
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{single ? (
|
||||||
|
<DialogActions
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
pb: 1.5,
|
||||||
|
pt: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Row 1: Insert from clipboard / Upload from file */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ContentPasteIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handlePasteClick}
|
||||||
|
sx={{ flex: 1, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Bild einfügen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handleUploadClick}
|
||||||
|
sx={{ flex: 1, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Bild hochladen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 2: Delete, Search new, Close */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteOutlinedIcon />}
|
||||||
|
disabled={busy || disabled || !hasPic}
|
||||||
|
onClick={this.handleDeleteSingle}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<ImageSearchIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handleSearchOpen}
|
||||||
|
>
|
||||||
|
Neues Suchen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={<CloseIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={this.handleClose}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DialogActions>
|
||||||
|
) : (
|
||||||
|
<DialogActions
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ContentPasteIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handlePasteClick}
|
||||||
|
>
|
||||||
|
Bild einfügen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handleUploadClick}
|
||||||
|
>
|
||||||
|
Bild hochladen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<ImageSearchIcon />}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={this.handleSearchOpen}
|
||||||
|
>
|
||||||
|
Bild suchen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<GoogleIcon />}
|
||||||
|
disabled={busy || disabled || !googleQuery}
|
||||||
|
onClick={this.handleOpenGoogleImages}
|
||||||
|
title={googleQuery ? `Google Bilder-Suche: ${googleQuery}` : 'Google'}
|
||||||
|
>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={this.handleClose}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
)}
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
|
{/* Hidden file input for file uploads */}
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={this.handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Optional embedded image search dialogs */}
|
||||||
|
{searchOpen && CustomSearchDialog ? (
|
||||||
|
<CustomSearchDialog
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={this.handleSearchClose}
|
||||||
|
/>
|
||||||
|
) : searchOpen && brand ? (
|
||||||
|
<BrandImageSearchDialog
|
||||||
|
open={searchOpen}
|
||||||
|
brand={brand}
|
||||||
|
onClose={this.handleSearchClose}
|
||||||
|
/>
|
||||||
|
) : searchOpen && article ? (
|
||||||
|
<ArticleImageSearchDialog
|
||||||
|
open={searchOpen}
|
||||||
|
article={article}
|
||||||
|
onClose={this.handleSearchClose}
|
||||||
|
onArticleUpdated={this.props.onArticleUpdated}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
@@ -78,7 +78,7 @@ export default class PwaUpdateDialog extends PureComponent {
|
|||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
|
<DialogContentText sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
|
||||||
Eine neue Version von <strong>Dashfront</strong> ist bereit.
|
Eine neue Version ist bereit.
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
<DialogContentText variant="body2" color="text.secondary">
|
<DialogContentText variant="body2" color="text.secondary">
|
||||||
Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen?
|
Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen?
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
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 Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
@@ -13,7 +13,9 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
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 './category/categoryRoute';
|
import { isKategorienPath, buildCategoryPath, KATEGORIEN_BASE } from './category/categoryRoute';
|
||||||
|
import CategoryBreadcrumbs from './category/CategoryBreadcrumbs';
|
||||||
|
import { getCategoryBreadcrumbs, resolveCategoryIdFromSlugPath } from '../utils/categoryUtils';
|
||||||
import categoryAutomat from '../automats/categoryAutomat';
|
import categoryAutomat from '../automats/categoryAutomat';
|
||||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||||
import { article, category } from '../api';
|
import { article, category } from '../api';
|
||||||
@@ -41,6 +43,7 @@ function selectCatSlice(s) {
|
|||||||
|
|
||||||
export default function SectionLayout({ sectionPath }) {
|
export default function SectionLayout({ sectionPath }) {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
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);
|
||||||
@@ -77,14 +80,14 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
const isDetail =
|
const isDetail =
|
||||||
isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc;
|
isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc;
|
||||||
|
|
||||||
const angebotLabel =
|
|
||||||
subNav?.find((item) => item.segment === '')?.title ?? subNav?.find((item) => item.segment === '')?.label ?? 'Angebot';
|
|
||||||
let pageTitle = isDetail
|
let pageTitle = isDetail
|
||||||
? title
|
? title
|
||||||
: (activeSub?.title ?? activeSub?.label ?? title);
|
: (activeSub?.title ?? activeSub?.label ?? title);
|
||||||
if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
|
if (pathname === '/auftrag/neuesangebot' || pathname === '/auftrag/neu' || pathname === '/auftrag/angebot/neu') pageTitle = 'Neues Angebot';
|
||||||
else if (isAngebotDoc) pageTitle = angebotLabel;
|
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neuer Auftrag';
|
||||||
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';
|
else if (pathname.startsWith('/auftrag/bestellung/')) pageTitle = 'Bestellung';
|
||||||
|
else if (pathname.startsWith('/auftrag/angebot/')) pageTitle = 'Angebot';
|
||||||
|
else if (isAngebotDoc) pageTitle = 'Angebot';
|
||||||
|
|
||||||
const showsRenameSwitch = activeSub?.segment === 'kategorien';
|
const showsRenameSwitch = activeSub?.segment === 'kategorien';
|
||||||
|
|
||||||
@@ -141,18 +144,39 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
importInputRef.current?.click();
|
importInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let crumbs = [];
|
||||||
|
if (showsRenameSwitch && categories && rootCategoryId != null) {
|
||||||
|
const slugPath = pathname
|
||||||
|
.slice(KATEGORIEN_BASE.length)
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((seg) => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(seg);
|
||||||
|
} catch {
|
||||||
|
return seg;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join('/');
|
||||||
|
const catId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath);
|
||||||
|
if (catId != null) crumbs = getCategoryBreadcrumbs(categories, catId, rootCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
const titleAction = showsRenameSwitch ? (
|
const titleAction = showsRenameSwitch ? (
|
||||||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
|
||||||
<ToggleElevatedPill
|
<CategoryBreadcrumbs
|
||||||
label="Edit"
|
crumbs={crumbs}
|
||||||
checked={renameMode}
|
variant="h6"
|
||||||
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
|
sx={{ m: 0, minWidth: 0 }}
|
||||||
/>
|
onNavigate={(id) => navigate(buildCategoryPath(categories, rootCategoryId, id))}
|
||||||
<ActionElevatedPill
|
|
||||||
label="Extras"
|
|
||||||
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
|
||||||
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
|
||||||
/>
|
/>
|
||||||
|
<Box sx={{ ml: 'auto', display: 'inline-flex' }}>
|
||||||
|
<ToggleElevatedPill
|
||||||
|
label="Edit"
|
||||||
|
checked={renameMode}
|
||||||
|
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<input
|
<input
|
||||||
ref={importInputRef}
|
ref={importInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -195,8 +219,9 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
title={pageTitle}
|
title={showsRenameSwitch ? '' : pageTitle}
|
||||||
showTitle={!isArticleDetail}
|
showTitle={!isArticleDetail}
|
||||||
|
titleWide={showsRenameSwitch ? 99 : undefined}
|
||||||
titleAction={titleAction}
|
titleAction={titleAction}
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user