diff --git a/CHANGELOG.md b/CHANGELOG.md
index 150f823..27d7c1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,6 +65,19 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
- Die Logosuche läuft nach dem Anlegen einer Firma im Hintergrund; der Aufruf
antwortet sofort.
- `make vendor-icons` erzeugt den simple-icons-Index neu.
+- Frontend auf React 18, TypeScript, Vite und TailwindCSS mit dunklem Standard-
+ Theme, umschaltbar und im Browser gespeichert.
+- Anmeldung, erzwungener Passwortwechsel und Layout mit Seitenleiste.
+- TanStack-Query-Client mit zentralen Fehler-Toasts und stiller Token-Erneuerung
+ bei abgelaufenem Access-Token.
+- Seiten für wiederkehrende Posten (inklusive Detail-Drawer mit Preishistorie,
+ Ratenfortschritt und Vertragsfristen), Buchungen, Firmen und Einstellungen.
+- Geführter RRULE-Editor mit sechs Vorlagen, Expertenmodus, deutscher
+ Klartextfassung und den nächsten fünf Terminen aus dem `preview`-Endpunkt.
+- Firmen als Kachelgrid mit Logo, Markenfarbe, Jahreskosten und Vertragszahl;
+ Logo-Auswahldialog mit Vorauswahl, erneuter Suche über eine Domain und Upload.
+- Beträge durchgängig über `Intl.NumberFormat('de-DE')`; Eingaben akzeptieren
+ deutsche wie englische Schreibweise.
### Geändert
diff --git a/Makefile b/Makefile
index 176d9eb..066f6ff 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,7 @@
# Entwicklungs-Helfer für moneyfy.
.DEFAULT_GOAL := help
BACKEND := backend
+FRONTEND := frontend
PY := $(BACKEND)/.venv/bin/python
UV := uv
@@ -49,5 +50,26 @@ format: ## Code formatieren und automatisch behebbare Regelverstöße korrigiere
cd $(BACKEND) && .venv/bin/ruff check --fix .
cd $(BACKEND) && .venv/bin/ruff format .
+.PHONY: fe-install
+fe-install: ## Frontend-Abhängigkeiten installieren
+ cd $(FRONTEND) && npm ci
+
+.PHONY: fe-dev
+fe-dev: ## Frontend mit Hot Reload starten (Proxy auf das Backend)
+ cd $(FRONTEND) && npm run dev
+
+.PHONY: fe-lint
+fe-lint: ## tsc und eslint
+ cd $(FRONTEND) && npm run typecheck
+ cd $(FRONTEND) && npm run lint
+
+.PHONY: fe-test
+fe-test: ## Frontend-Tests ausführen
+ cd $(FRONTEND) && npm run test
+
+.PHONY: fe-build
+fe-build: ## Produktionsbundle bauen
+ cd $(FRONTEND) && npm run build
+
.PHONY: check
-check: lint test ## Lint und Tests zusammen
+check: lint test fe-lint fe-test ## Alle Prüfungen von Backend und Frontend
diff --git a/README.md b/README.md
index dad0e57..cd95b02 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,15 @@ make seed # Kategoriebaum und Standardregeln
make dev # http://localhost:8000/api/docs
```
-Nützliche Ziele: `make test`, `make lint`, `make format`, `make check`.
+In einem zweiten Terminal das Frontend, es leitet `/api` an das Backend weiter:
+
+```bash
+make fe-install
+make fe-dev # http://localhost:5173
+```
+
+Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
+`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
## Konfiguration
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..4c8daff
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.vite/
+*.tsbuildinfo
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..2f1acd6
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,33 @@
+import js from "@eslint/js";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import globals from "globals";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist", "coverage", "node_modules"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: { ...globals.browser, ...globals.es2022 },
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ "@typescript-eslint/consistent-type-imports": [
+ "error",
+ { prefer: "type-imports", fixStyle: "inline-type-imports" },
+ ],
+ },
+ },
+);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..30d01ef
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+ moneyfy
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..f83ff13
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,6109 @@
+{
+ "name": "moneyfy-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "moneyfy-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "lucide-react": "^0.468.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0",
+ "recharts": "^2.15.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^26.5.0",
+ "@types/react": "^18.3.14",
+ "@types/react-dom": "^18.3.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "globals": "^15.13.0",
+ "jsdom": "^25.0.1",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "typescript-eslint": "^8.18.0",
+ "vite": "^6.0.3",
+ "vitest": "^3.2.7"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz",
+ "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz",
+ "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.2",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+ "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.4",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
+ "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.102.8",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
+ "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.102.8",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
+ "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.102.8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz",
+ "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
+ "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.70.0",
+ "@typescript-eslint/type-utils": "8.70.0",
+ "@typescript-eslint/utils": "8.70.0",
+ "@typescript-eslint/visitor-keys": "8.70.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.70.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.9",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
+ "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
+ "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.70.0",
+ "@typescript-eslint/types": "8.70.0",
+ "@typescript-eslint/typescript-estree": "8.70.0",
+ "@typescript-eslint/visitor-keys": "8.70.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
+ "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.70.0",
+ "@typescript-eslint/types": "^8.70.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
+ "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.0",
+ "@typescript-eslint/visitor-keys": "8.70.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
+ "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
+ "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.0",
+ "@typescript-eslint/typescript-estree": "8.70.0",
+ "@typescript-eslint/utils": "8.70.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
+ "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
+ "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.70.0",
+ "@typescript-eslint/tsconfig-utils": "8.70.0",
+ "@typescript-eslint/types": "8.70.0",
+ "@typescript-eslint/visitor-keys": "8.70.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
+ "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.70.0",
+ "@typescript-eslint/types": "8.70.0",
+ "@typescript-eslint/typescript-estree": "8.70.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
+ "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.5",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz",
+ "integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.9",
+ "caniuse-lite": "^1.0.30001810",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.21",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
+ "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.9",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
+ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.20",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.420",
+ "node-releases": "^2.0.54",
+ "update-browserslist-db": "^1.3.2"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.425",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz",
+ "integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.2",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
+ "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
+ "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "15.15.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
+ "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "25.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.1.0",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.468.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
+ "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.55",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
+ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.27",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
+ "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
+ "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
+ "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4",
+ "react-router": "6.30.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
+ "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/recharts/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
+ "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.70.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
+ "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.70.0",
+ "@typescript-eslint/parser": "8.70.0",
+ "@typescript-eslint/typescript-estree": "8.70.0",
+ "@typescript-eslint/utils": "8.70.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..65203f1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "moneyfy-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "lucide-react": "^0.468.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0",
+ "recharts": "^2.15.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^26.5.0",
+ "@types/react": "^18.3.14",
+ "@types/react-dom": "^18.3.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "globals": "^15.13.0",
+ "jsdom": "^25.0.1",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "typescript-eslint": "^8.18.0",
+ "vite": "^6.0.3",
+ "vitest": "^3.2.7"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..ba6dacf
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..22a2871
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,43 @@
+/** Routen der Anwendung und die Weichen für die Anmeldung. */
+
+import { Navigate, Route, Routes } from "react-router-dom";
+
+import { AppLayout } from "@/components/layout/AppLayout";
+import { Skeleton } from "@/components/ui/Feedback";
+import { useMe } from "@/hooks/useAuth";
+import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
+import { LoginPage } from "@/pages/LoginPage";
+import { MerchantsPage } from "@/pages/MerchantsPage";
+import { RecurrencesPage } from "@/pages/RecurrencesPage";
+import { SettingsPage } from "@/pages/SettingsPage";
+import { TransactionsPage } from "@/pages/TransactionsPage";
+
+export function App() {
+ const { data: benutzer, isLoading } = useMe();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!benutzer) return ;
+
+ // Solange das Startpasswort gilt, sind alle Fachrouten gesperrt.
+ if (benutzer.must_change_password) return ;
+
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/frontend/src/components/EntitySelects.tsx b/frontend/src/components/EntitySelects.tsx
new file mode 100644
index 0000000..dc0a2d1
--- /dev/null
+++ b/frontend/src/components/EntitySelects.tsx
@@ -0,0 +1,107 @@
+/** Auswahlfelder für Konten, Kategorien und Firmen. */
+
+import { Select } from "@/components/ui/Field";
+import { useAccounts, useCategoryTree, useMerchants } from "@/hooks/useEntities";
+import type { EntryKind } from "@/types/api";
+
+export function AccountSelect({
+ id,
+ value,
+ onChange,
+ required,
+}: {
+ id?: string;
+ value: number | null;
+ onChange: (value: number) => void;
+ required?: boolean;
+}) {
+ const { data: konten = [] } = useAccounts(true);
+
+ return (
+
+ );
+}
+
+export function CategorySelect({
+ id,
+ value,
+ kind,
+ onChange,
+ required,
+}: {
+ id?: string;
+ value: number | null;
+ kind: EntryKind;
+ onChange: (value: number) => void;
+ required?: boolean;
+}) {
+ const { data: baum = [] } = useCategoryTree();
+ const passend = baum.filter((oberkategorie) => oberkategorie.kind === kind);
+
+ return (
+
+ );
+}
+
+export function MerchantSelect({
+ id,
+ value,
+ onChange,
+}: {
+ id?: string;
+ value: number | null;
+ onChange: (value: number | null) => void;
+}) {
+ const { data } = useMerchants();
+ const firmen = data?.items ?? [];
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/MerchantLogo.tsx b/frontend/src/components/MerchantLogo.tsx
new file mode 100644
index 0000000..b281736
--- /dev/null
+++ b/frontend/src/components/MerchantLogo.tsx
@@ -0,0 +1,51 @@
+/** Logo einer Firma; ohne hinterlegtes Bild erscheinen die Initialen. */
+
+import { useThemeStore } from "@/store/theme";
+import { cn } from "@/lib/cn";
+import { initialsOf } from "@/lib/initials";
+import { logoUrl } from "@/lib/api";
+import type { Merchant } from "@/types/api";
+
+export interface MerchantLogoProps {
+ merchant: Pick;
+ size?: number;
+ className?: string;
+}
+
+export function MerchantLogo({ merchant, size = 40, className }: MerchantLogoProps) {
+ const theme = useThemeStore((zustand) => zustand.theme);
+ // Auf dunklem Grund die aufgehellte Variante nehmen – sie erfüllt den Kontrast.
+ const farbe =
+ (theme === "dark" ? merchant.brand_color_dark : merchant.brand_color) ??
+ merchant.brand_color ??
+ "rgb(var(--color-faint))";
+
+ return (
+
+ {merchant.logo_asset_id ? (
+
})
+ ) : (
+
+ {initialsOf(merchant.name)}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/MerchantLogoDialog.test.tsx b/frontend/src/components/MerchantLogoDialog.test.tsx
new file mode 100644
index 0000000..65058ba
--- /dev/null
+++ b/frontend/src/components/MerchantLogoDialog.test.tsx
@@ -0,0 +1,172 @@
+/** Tests des Logo-Auswahldialogs. */
+
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { MerchantLogoDialog } from "@/components/MerchantLogoDialog";
+import { renderWithProviders } from "@/test/utils";
+import type { LogoCandidate, Merchant } from "@/types/api";
+
+const FIRMA: Merchant = {
+ id: 3,
+ name: "Netflix",
+ normalized_name: "netflix",
+ domain: "netflix.com",
+ aliases: [],
+ logo_asset_id: null,
+ brand_color: null,
+ brand_color_dark: null,
+ logo_source: null,
+ logo_status: "pending",
+ created_at: "2026-03-01T10:00:00+01:00",
+};
+
+const KANDIDATEN: LogoCandidate[] = [
+ {
+ candidate_id: 11,
+ source: "simple-icons",
+ title: "Netflix",
+ score: 1,
+ mime: "image/svg+xml",
+ width: null,
+ height: null,
+ brand_color: "#e50914",
+ is_preselected: true,
+ },
+ {
+ candidate_id: 12,
+ source: "favicon",
+ title: "Netflix",
+ score: 0.5,
+ mime: "image/png",
+ width: 128,
+ height: 128,
+ brand_color: "#e40813",
+ is_preselected: false,
+ },
+ {
+ candidate_id: 13,
+ source: "generated",
+ title: "Netflix",
+ score: 0.1,
+ mime: "image/svg+xml",
+ width: 96,
+ height: 96,
+ brand_color: "#f97316",
+ is_preselected: false,
+ },
+];
+
+/** Antwortet auf Suche und Auswahl und merkt sich die gesendeten Anfragen. */
+function mockLogoApi(kandidaten: LogoCandidate[] = KANDIDATEN) {
+ const anfragen: { url: string; method: string; body: string | null }[] = [];
+
+ const nachbildung = vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
+ anfragen.push({
+ url,
+ method: init?.method ?? "GET",
+ body: typeof init?.body === "string" ? init.body : null,
+ });
+
+ if (url.includes("/logo/search")) {
+ return new Response(JSON.stringify({ merchant_id: 3, candidates: kandidaten }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ if (url.includes("/logo/select")) {
+ return new Response(JSON.stringify({ ...FIRMA, logo_asset_id: 11, logo_status: "manual" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
+ });
+
+ vi.stubGlobal("fetch", nachbildung);
+ return anfragen;
+}
+
+describe("MerchantLogoDialog", () => {
+ beforeEach(() => {
+ mockLogoApi();
+ });
+
+ it("sucht beim Öffnen und wählt den besten Treffer vor", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("radiogroup", { name: "Logos" })).toBeInTheDocument();
+ });
+
+ const auswahl = screen.getAllByRole("radio");
+ expect(auswahl).toHaveLength(3);
+ // Der Kandidat mit is_preselected ist markiert, genau einer.
+ expect(auswahl.filter((knopf) => knopf.getAttribute("aria-checked") === "true")).toHaveLength(1);
+ expect(auswahl[0]).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByAltText("Vorschlag von Markenkatalog")).toBeInTheDocument();
+ expect(screen.getByAltText("Vorschlag von Erzeugt")).toBeInTheDocument();
+ });
+
+ it("übernimmt einen anderen Kandidaten", async () => {
+ const anfragen = mockLogoApi();
+ const nutzer = userEvent.setup();
+ const schliessen = vi.fn();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
+ await nutzer.click(screen.getAllByRole("radio")[1]!);
+ await nutzer.click(screen.getByRole("button", { name: "Übernehmen" }));
+
+ await waitFor(() => expect(schliessen).toHaveBeenCalled());
+ const auswahl = anfragen.find((eintrag) => eintrag.url.includes("/logo/select"));
+ expect(auswahl?.body).toBe(JSON.stringify({ candidate_id: 12 }));
+ });
+
+ it("sucht mit einer geänderten Domain erneut", async () => {
+ const anfragen = mockLogoApi();
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
+
+ const feld = screen.getByLabelText("Domain");
+ await nutzer.clear(feld);
+ await nutzer.type(feld, "netflix.de");
+ await nutzer.click(screen.getByRole("button", { name: "Suchen" }));
+
+ await waitFor(() => {
+ expect(
+ anfragen.some((eintrag) => eintrag.url.includes("domain=netflix.de")),
+ ).toBe(true);
+ });
+ });
+
+ it("zeigt die hinterlegte Domain vor", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Domain")).toHaveValue("netflix.com");
+ });
+ });
+
+ it("bietet den Upload an, wenn nichts gefunden wurde", async () => {
+ mockLogoApi([]);
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Keine Vorschläge/)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("button", { name: /Eigenes Logo hochladen/ })).toBeInTheDocument();
+ });
+
+ it("rendert nichts ohne Firma", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/frontend/src/components/MerchantLogoDialog.tsx b/frontend/src/components/MerchantLogoDialog.tsx
new file mode 100644
index 0000000..59be719
--- /dev/null
+++ b/frontend/src/components/MerchantLogoDialog.tsx
@@ -0,0 +1,214 @@
+/**
+ * Auswahldialog für Firmenlogos.
+ *
+ * Die Kandidaten liefert das Backend samt Vorauswahl; alle liegen bereits im
+ * lokalen Cache und werden über `/api/logos/{id}` angezeigt.
+ */
+
+import { type ChangeEvent, useEffect, useRef, useState } from "react";
+
+import { Search, Upload } from "lucide-react";
+
+import { Button } from "@/components/ui/Button";
+import { Field, Input } from "@/components/ui/Field";
+import { Skeleton } from "@/components/ui/Feedback";
+import { Modal } from "@/components/ui/Modal";
+import { useLogoSearch, useSelectLogo, useUploadLogo } from "@/hooks/useEntities";
+import { logoUrl } from "@/lib/api";
+import { cn } from "@/lib/cn";
+import { toast } from "@/store/toast";
+import type { LogoCandidate, LogoSource, Merchant } from "@/types/api";
+
+const QUELLEN: Record = {
+ "simple-icons": "Markenkatalog",
+ logodev: "logo.dev",
+ brandfetch: "Brandfetch",
+ favicon: "Favicon",
+ upload: "Hochgeladen",
+ generated: "Erzeugt",
+};
+
+const MAX_UPLOAD_BYTES = 1_048_576;
+
+export function MerchantLogoDialog({
+ merchant,
+ open,
+ onClose,
+}: {
+ merchant: Merchant | null;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const [kandidaten, setKandidaten] = useState([]);
+ const [gewaehlt, setGewaehlt] = useState(null);
+ const [domain, setDomain] = useState("");
+ const dateifeld = useRef(null);
+
+ const suchen = useLogoSearch();
+ const uebernehmen = useSelectLogo();
+ const hochladen = useUploadLogo();
+
+ // Beim Öffnen einmal automatisch suchen.
+ useEffect(() => {
+ if (!open || !merchant) return;
+ setDomain(merchant.domain ?? "");
+ setKandidaten([]);
+ setGewaehlt(null);
+ suchen.mutate(
+ { merchantId: merchant.id },
+ {
+ onSuccess: (ergebnis) => {
+ setKandidaten(ergebnis.candidates);
+ const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
+ setGewaehlt(beste?.candidate_id ?? ergebnis.candidates[0]?.candidate_id ?? null);
+ },
+ },
+ );
+ // Nur beim Öffnen bzw. Wechsel der Firma.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, merchant?.id]);
+
+ function erneutSuchen() {
+ if (!merchant) return;
+ suchen.mutate(
+ { merchantId: merchant.id, domain: domain.trim() || undefined },
+ {
+ onSuccess: (ergebnis) => {
+ setKandidaten(ergebnis.candidates);
+ const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
+ setGewaehlt(beste?.candidate_id ?? null);
+ },
+ },
+ );
+ }
+
+ function dateiGewaehlt(ereignis: ChangeEvent) {
+ const datei = ereignis.target.files?.[0];
+ ereignis.target.value = "";
+ if (!datei || !merchant) return;
+
+ if (datei.size > MAX_UPLOAD_BYTES) {
+ toast.error("Die Datei ist zu groß.", "Erlaubt sind höchstens 1 MB.");
+ return;
+ }
+ hochladen.mutate({ merchantId: merchant.id, file: datei }, { onSuccess: onClose });
+ }
+
+ function speichern() {
+ if (!merchant || gewaehlt === null) return;
+ uebernehmen.mutate({ merchantId: merchant.id, candidateId: gewaehlt }, { onSuccess: onClose });
+ }
+
+ if (!merchant) return null;
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+
+ {(id) => (
+ setDomain(ereignis.target.value)}
+ onKeyDown={(ereignis) => {
+ if (ereignis.key === "Enter") {
+ ereignis.preventDefault();
+ erneutSuchen();
+ }
+ }}
+ />
+ )}
+
+
+
+
+ {suchen.isPending ? (
+
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+
+ ) : kandidaten.length > 0 ? (
+
+ {kandidaten.map((kandidat) => (
+ -
+
+
+ ))}
+
+ ) : (
+
+ Keine Vorschläge. Lade stattdessen ein eigenes Bild hoch.
+
+ )}
+
+
+
+
+
SVG, PNG oder JPEG bis 1 MB.
+
+
+
+ );
+}
diff --git a/frontend/src/components/RRuleEditor.test.tsx b/frontend/src/components/RRuleEditor.test.tsx
new file mode 100644
index 0000000..ddc74b2
--- /dev/null
+++ b/frontend/src/components/RRuleEditor.test.tsx
@@ -0,0 +1,194 @@
+/** Tests des geführten RRULE-Editors. */
+
+import { useState } from "react";
+
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RRuleEditor } from "@/components/RRuleEditor";
+import { mockFetch, renderWithProviders } from "@/test/utils";
+
+/** Hülle, die den Zustand hält – so wie es das Formular tut. */
+function Huelle({
+ start = "FREQ=MONTHLY;BYMONTHDAY=1",
+ recurrenceId = null,
+}: {
+ start?: string;
+ recurrenceId?: number | null;
+}) {
+ const [regel, setRegel] = useState(start);
+ return (
+ <>
+
+
+ >
+ );
+}
+
+describe("RRuleEditor", () => {
+ beforeEach(() => {
+ mockFetch({});
+ });
+
+ it("zeigt Klartext und Regel zur Vorgabe", () => {
+ renderWithProviders();
+
+ expect(screen.getByText("Jeden 1. des Monats, ab 01.03.2026")).toBeInTheDocument();
+ expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=1");
+ });
+
+ it("baut beim Wechsel auf quartalsweise die passende Regel", async () => {
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await nutzer.click(screen.getByRole("button", { name: "Quartalsweise" }));
+
+ expect(screen.getByTestId("regel")).toHaveTextContent(
+ "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
+ );
+ expect(screen.getByText(/Quartalsweise am 1\./)).toBeInTheDocument();
+ });
+
+ it("übernimmt den gewählten Tag im Monat", async () => {
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await nutzer.selectOptions(screen.getByLabelText("Tag im Monat"), "15");
+
+ expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=15");
+ });
+
+ it("erzeugt für den Monatsletzten die BYSETPOS-Regel", async () => {
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await nutzer.click(screen.getByRole("button", { name: "Monatsletzter" }));
+
+ expect(screen.getByTestId("regel")).toHaveTextContent(
+ "FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1",
+ );
+ expect(screen.getByText(/am letzten Tag/)).toBeInTheDocument();
+ });
+
+ it("erlaubt im Expertenmodus die freie Eingabe", async () => {
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
+ const feld = screen.getByLabelText(/RRULE/);
+ await nutzer.clear(feld);
+ await nutzer.type(feld, "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
+
+ expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
+ expect(screen.getByText(/Jährlich am 4\. Juni/)).toBeInTheDocument();
+ });
+
+ it("wählt zu einer bestehenden Regel die passende Vorlage vor", () => {
+ renderWithProviders();
+
+ expect(screen.getByRole("button", { name: "Wöchentlich" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ expect(screen.getByRole("button", { name: "Montag" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "Donnerstag" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ });
+
+ it("weist darauf hin, dass Termine erst nach dem Speichern erscheinen", () => {
+ renderWithProviders();
+
+ expect(screen.getByText(/sobald der Posten gespeichert ist/)).toBeInTheDocument();
+ });
+
+ it("zeigt die vom Backend berechneten Termine", async () => {
+ mockFetch({
+ "/recurrences/7/preview": [
+ {
+ recurrence_id: 7,
+ recurrence_title: "Miete",
+ kind: "expense",
+ category_id: 1,
+ merchant_id: null,
+ account_id: 1,
+ nominal_date: "2026-03-01",
+ due_date: "2026-03-02",
+ effective_date: "2026-03-02",
+ amount: "950.00",
+ actual_amount: null,
+ effective_amount: "950.00",
+ status: "planned",
+ is_variable: false,
+ occurrence_id: null,
+ note: null,
+ installment_number: null,
+ installments_total: null,
+ },
+ {
+ recurrence_id: 7,
+ recurrence_title: "Miete",
+ kind: "expense",
+ category_id: 1,
+ merchant_id: null,
+ account_id: 1,
+ nominal_date: "2026-04-01",
+ due_date: "2026-04-01",
+ effective_date: "2026-04-01",
+ amount: "950.00",
+ actual_amount: null,
+ effective_amount: "950.00",
+ status: "planned",
+ is_variable: false,
+ occurrence_id: null,
+ note: null,
+ installment_number: null,
+ installments_total: null,
+ },
+ ],
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Nächste Termine")).toBeInTheDocument();
+ });
+ // Der 01.03. ist ein Sonntag – angezeigt wird der verschobene Zahltag.
+ expect(screen.getByText("02.03.2026")).toBeInTheDocument();
+ expect(screen.getByText("01.04.2026")).toBeInTheDocument();
+ });
+
+ it("meldet eine Regel ohne Termine", async () => {
+ mockFetch({ "/recurrences/7/preview": [] });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Diese Regel ergibt keine Termine.")).toBeInTheDocument();
+ });
+ });
+
+ it("reicht einen Fehler des Servers an den Expertenmodus durch", async () => {
+ const nutzer = userEvent.setup();
+ const bei = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
+
+ expect(screen.getByText("Ungültige Wiederholungsregel")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/RRuleEditor.tsx b/frontend/src/components/RRuleEditor.tsx
new file mode 100644
index 0000000..107a559
--- /dev/null
+++ b/frontend/src/components/RRuleEditor.tsx
@@ -0,0 +1,282 @@
+/**
+ * Geführter Editor für Wiederholungsregeln mit Expertenmodus.
+ *
+ * Unabhängig vom gewählten Weg zeigt der Editor immer eine deutsche
+ * Klartextfassung und – sobald der Posten gespeichert ist – die nächsten fünf
+ * Termine, die das Backend tatsächlich berechnet.
+ */
+
+import { useEffect, useMemo, useState } from "react";
+
+import { CalendarClock, Info } from "lucide-react";
+
+import { Skeleton } from "@/components/ui/Feedback";
+import { Field, Input, Select } from "@/components/ui/Field";
+import { useRecurrencePreview } from "@/hooks/useEntities";
+import { cn } from "@/lib/cn";
+import { addMonthsIso, formatDate } from "@/lib/format";
+import {
+ MONTHS,
+ PRESETS,
+ type PresetId,
+ type PresetState,
+ WEEKDAYS,
+ describeRRule,
+ presetFromRule,
+ ruleFromPreset,
+} from "@/lib/rrule";
+
+export interface RRuleEditorProps {
+ rrule: string;
+ dtstart: string;
+ onChange: (rrule: string) => void;
+ /** Ist der Posten bereits gespeichert, kommen die Termine vom Server. */
+ recurrenceId?: number | null;
+ error?: string | null;
+}
+
+export function RRuleEditor({
+ rrule,
+ dtstart,
+ onChange,
+ recurrenceId = null,
+ error,
+}: RRuleEditorProps) {
+ const [zustand, setZustand] = useState(() => presetFromRule(rrule, dtstart));
+
+ // Wird ein anderer Posten geladen, den Editor neu aufsetzen.
+ useEffect(() => {
+ setZustand(presetFromRule(rrule, dtstart));
+ // Nur beim Wechsel des bearbeiteten Postens.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [recurrenceId]);
+
+ function aendern(teil: Partial) {
+ const naechster = { ...zustand, ...teil };
+ setZustand(naechster);
+ onChange(ruleFromPreset(naechster));
+ }
+
+ const klartext = useMemo(() => describeRRule(rrule, dtstart), [rrule, dtstart]);
+
+ return (
+
+
+ {(id) => (
+
+ {PRESETS.map((vorlage) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
{klartext}
+
{rrule || "–"}
+
+
+
+
+ {zustand.preset !== "custom" && error &&
{error}
}
+
+
+
+ );
+}
+
+/** Die nächsten fünf Termine – berechnet vom Backend, nicht im Browser. */
+function NextDates({
+ recurrenceId,
+ dtstart,
+}: {
+ recurrenceId: number | null;
+ dtstart: string;
+}) {
+ const von = dtstart;
+ const bis = addMonthsIso(dtstart, 36);
+ const { data, isLoading } = useRecurrencePreview(recurrenceId, von, bis);
+
+ if (recurrenceId === null) {
+ return (
+
+ Die nächsten Termine erscheinen, sobald der Posten gespeichert ist.
+
+ );
+ }
+
+ if (isLoading) return ;
+
+ const termine = (data ?? []).slice(0, 5);
+ if (termine.length === 0) {
+ return Diese Regel ergibt keine Termine.
;
+ }
+
+ return (
+
+
+
+ Nächste Termine
+
+
+ {termine.map((termin) => (
+ -
+ {formatDate(termin.due_date)}
+ {termin.due_date !== termin.nominal_date && (
+ ↦
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/RecurrenceDetail.tsx b/frontend/src/components/RecurrenceDetail.tsx
new file mode 100644
index 0000000..866398a
--- /dev/null
+++ b/frontend/src/components/RecurrenceDetail.tsx
@@ -0,0 +1,365 @@
+/** Detailansicht eines wiederkehrenden Postens als seitlicher Drawer. */
+
+import { type FormEvent, useState } from "react";
+
+import { CalendarX2, Pencil, Plus, Trash2, X } from "lucide-react";
+
+import { useCategoryLookup } from "@/hooks/useCategoryLookup";
+import { MerchantLogo } from "@/components/MerchantLogo";
+import { Button } from "@/components/ui/Button";
+import { Badge, ConfirmDialog, Skeleton } from "@/components/ui/Feedback";
+import { Field, Input } from "@/components/ui/Field";
+import { MoneyInput } from "@/components/ui/MoneyInput";
+import {
+ useAddAmountVersion,
+ useCancelContract,
+ useDeleteRecurrence,
+ useRecurrence,
+} from "@/hooks/useEntities";
+import { formatDate, formatMoney, relativeDays, todayIso } from "@/lib/format";
+import { describeRRule } from "@/lib/rrule";
+import type { Recurrence } from "@/types/api";
+
+export function RecurrenceDetailDrawer({
+ recurrenceId,
+ onClose,
+ onEdit,
+}: {
+ recurrenceId: number | null;
+ onClose: () => void;
+ onEdit: (recurrence: Recurrence) => void;
+}) {
+ const { data: posten, isLoading } = useRecurrence(recurrenceId);
+ const entfernen = useDeleteRecurrence();
+ const kuendigen = useCancelContract();
+ const kategorieName = useCategoryLookup();
+
+ const [preisFormular, setPreisFormular] = useState(false);
+ const [loeschen, setLoeschen] = useState(false);
+
+ if (recurrenceId === null) return null;
+
+ return (
+
+
+
+
+
+
setLoeschen(false)}
+ onConfirm={() => {
+ if (!posten) return;
+ entfernen.mutate(posten.id, {
+ onSuccess: () => {
+ setLoeschen(false);
+ onClose();
+ },
+ });
+ }}
+ />
+
+ );
+}
+
+function Kennzahl({
+ label,
+ value,
+ tone = "default",
+}: {
+ label: string;
+ value: string;
+ tone?: "default" | "positive";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+function Zeile({ label, wert }: { label: string; wert: string }) {
+ return (
+
+
{label}
+ {wert}
+
+ );
+}
+
+function AmountVersionForm({
+ recurrenceId,
+ onDone,
+}: {
+ recurrenceId: number;
+ onDone: () => void;
+}) {
+ const [betrag, setBetrag] = useState("");
+ const [gueltigAb, setGueltigAb] = useState(todayIso());
+ const anlegen = useAddAmountVersion();
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ if (!betrag) return;
+ anlegen.mutate(
+ { recurrenceId, daten: { amount: betrag, valid_from: gueltigAb } },
+ { onSuccess: onDone },
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/RecurrenceForm.tsx b/frontend/src/components/RecurrenceForm.tsx
new file mode 100644
index 0000000..ff2fe47
--- /dev/null
+++ b/frontend/src/components/RecurrenceForm.tsx
@@ -0,0 +1,452 @@
+/** Formular für wiederkehrende Posten samt RRULE-Editor. */
+
+import { type FormEvent, useState } from "react";
+
+import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
+import { RRuleEditor } from "@/components/RRuleEditor";
+import { Button } from "@/components/ui/Button";
+import { Checkbox, Field, Input, Select, Textarea } from "@/components/ui/Field";
+import { Modal } from "@/components/ui/Modal";
+import { MoneyInput } from "@/components/ui/MoneyInput";
+import { useAccounts, useSaveRecurrence } from "@/hooks/useEntities";
+import { ApiError } from "@/lib/api";
+import { todayIso } from "@/lib/format";
+import type { BusinessDayShift, EntryKind, Recurrence } from "@/types/api";
+
+interface FormularZustand {
+ kind: EntryKind;
+ title: string;
+ amount: string;
+ rrule: string;
+ dtstart: string;
+ until: string;
+ category_id: number | null;
+ account_id: number | null;
+ merchant_id: number | null;
+ business_day_shift: BusinessDayShift;
+ is_variable: boolean;
+ reserve_enabled: boolean;
+ is_active: boolean;
+ notes: string;
+ installments_total: string;
+ principal_amount: string;
+ contract_start: string;
+ contract_min_term_months: string;
+ contract_notice_period_days: string;
+ contract_auto_renew_months: string;
+}
+
+function leeresFormular(kontoId: number | null): FormularZustand {
+ return {
+ kind: "expense",
+ title: "",
+ amount: "",
+ rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
+ dtstart: todayIso(),
+ until: "",
+ category_id: null,
+ account_id: kontoId,
+ merchant_id: null,
+ business_day_shift: "next",
+ is_variable: false,
+ reserve_enabled: false,
+ is_active: true,
+ notes: "",
+ installments_total: "",
+ principal_amount: "",
+ contract_start: "",
+ contract_min_term_months: "",
+ contract_notice_period_days: "",
+ contract_auto_renew_months: "",
+ };
+}
+
+function ausPosten(posten: Recurrence): FormularZustand {
+ return {
+ kind: posten.kind,
+ title: posten.title,
+ amount: posten.amount,
+ rrule: posten.rrule,
+ dtstart: posten.dtstart,
+ until: posten.until ?? "",
+ category_id: posten.category_id,
+ account_id: posten.account_id,
+ merchant_id: posten.merchant_id,
+ business_day_shift: posten.business_day_shift,
+ is_variable: posten.is_variable,
+ reserve_enabled: posten.reserve_enabled,
+ is_active: posten.is_active,
+ notes: posten.notes ?? "",
+ installments_total: posten.installments_total?.toString() ?? "",
+ principal_amount: posten.principal_amount ?? "",
+ contract_start: posten.contract_start ?? "",
+ contract_min_term_months: posten.contract_min_term_months?.toString() ?? "",
+ contract_notice_period_days: posten.contract_notice_period_days?.toString() ?? "",
+ contract_auto_renew_months: posten.contract_auto_renew_months?.toString() ?? "",
+ };
+}
+
+function zahlOderNull(wert: string): number | null {
+ const zahl = Number.parseInt(wert, 10);
+ return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
+}
+
+export function RecurrenceFormDialog({
+ recurrence,
+ open,
+ onClose,
+}: {
+ recurrence: Recurrence | null | undefined;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const { data: konten = [] } = useAccounts(true);
+ const speichern = useSaveRecurrence();
+ const [formular, setFormular] = useState(() => leeresFormular(null));
+ const [initialisiert, setInitialisiert] = useState(undefined);
+ const [erweitert, setErweitert] = useState(false);
+
+ if (open && initialisiert !== (recurrence?.id ?? null)) {
+ setFormular(recurrence ? ausPosten(recurrence) : leeresFormular(konten[0]?.id ?? null));
+ setErweitert(Boolean(recurrence?.installments_total || recurrence?.contract_min_term_months));
+ setInitialisiert(recurrence?.id ?? null);
+ }
+
+ function setzen(teil: Partial) {
+ setFormular((alt) => ({ ...alt, ...teil }));
+ }
+
+ const absendbar =
+ formular.title.trim() !== "" &&
+ formular.amount !== "" &&
+ formular.rrule.trim() !== "" &&
+ formular.category_id !== null &&
+ formular.account_id !== null;
+
+ // Fehler zur Wiederholungsregel gehören an den Editor, nicht in einen Toast.
+ const regelFehler =
+ speichern.error instanceof ApiError &&
+ (speichern.error.code === "invalid_rrule" ||
+ (speichern.error.fieldMessage ?? "").toLowerCase().includes("wiederholungsregel"))
+ ? (speichern.error.fieldMessage ?? speichern.error.message)
+ : null;
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ if (!absendbar) return;
+
+ speichern.mutate(
+ {
+ id: recurrence?.id,
+ daten: {
+ kind: formular.kind,
+ title: formular.title.trim(),
+ amount: formular.amount,
+ rrule: formular.rrule.trim(),
+ dtstart: formular.dtstart,
+ until: formular.until || null,
+ category_id: formular.category_id!,
+ account_id: formular.account_id!,
+ merchant_id: formular.merchant_id,
+ business_day_shift: formular.business_day_shift,
+ is_variable: formular.is_variable,
+ reserve_enabled: formular.reserve_enabled,
+ is_active: formular.is_active,
+ notes: formular.notes.trim() || null,
+ installments_total: zahlOderNull(formular.installments_total),
+ principal_amount: formular.principal_amount || null,
+ contract_start: formular.contract_start || null,
+ contract_min_term_months: zahlOderNull(formular.contract_min_term_months),
+ contract_notice_period_days: formular.contract_notice_period_days
+ ? Number.parseInt(formular.contract_notice_period_days, 10)
+ : null,
+ contract_auto_renew_months: zahlOderNull(formular.contract_auto_renew_months),
+ },
+ },
+ {
+ onSuccess: () => {
+ setInitialisiert(undefined);
+ onClose();
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..02a05d6
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -0,0 +1,113 @@
+/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
+
+import { useState } from "react";
+import { Outlet } from "react-router-dom";
+
+import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
+
+import { Sidebar } from "@/components/layout/Sidebar";
+import { Button } from "@/components/ui/Button";
+import { useLogout, useMe } from "@/hooks/useAuth";
+import { useThemeStore } from "@/store/theme";
+
+export function AppLayout() {
+ const [menuOffen, setMenuOffen] = useState(false);
+ const { data: benutzer } = useMe();
+ const abmelden = useLogout();
+ const theme = useThemeStore((zustand) => zustand.theme);
+ const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
+
+ return (
+
+ {/* Seitenleiste ab Bildschirmbreite lg dauerhaft sichtbar */}
+
+
+ {menuOffen && (
+
+
setMenuOffen(false)}
+ aria-hidden
+ />
+
+
+ )}
+
+
+
+ );
+}
+
+/** Kopfzeile einer Seite mit Titel und optionalen Aktionen. */
+export function PageHeader({
+ title,
+ description,
+ actions,
+}: {
+ title: string;
+ description?: string;
+ actions?: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..197c26d
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -0,0 +1,51 @@
+import { NavLink } from "react-router-dom";
+
+import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+
+interface NavEintrag {
+ to: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+const NAVIGATION: NavEintrag[] = [
+ { to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
+ { to: "/transactions", label: "Buchungen", icon: Receipt },
+ { to: "/merchants", label: "Firmen", icon: Building2 },
+ { to: "/settings", label: "Einstellungen", icon: Settings },
+];
+
+export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
new file mode 100644
index 0000000..204b02a
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,50 @@
+import { type ButtonHTMLAttributes, forwardRef } from "react";
+
+import { Loader2 } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+
+type Variant = "primary" | "secondary" | "ghost" | "danger";
+type Size = "sm" | "md";
+
+const VARIANTEN: Record
= {
+ primary: "bg-accent text-accent-ink hover:brightness-110 disabled:hover:brightness-100",
+ secondary: "border border-line bg-raised text-ink hover:bg-line",
+ ghost: "text-muted hover:bg-raised hover:text-ink",
+ danger: "border border-negative/40 bg-negative/10 text-negative hover:bg-negative/20",
+};
+
+const GROESSEN: Record = {
+ sm: "h-8 gap-1.5 px-2.5 text-xs",
+ md: "h-10 gap-2 px-4 text-sm",
+};
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: Variant;
+ size?: Size;
+ loading?: boolean;
+}
+
+export const Button = forwardRef(function Button(
+ { variant = "secondary", size = "md", loading = false, className, children, disabled, ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/frontend/src/components/ui/Feedback.tsx b/frontend/src/components/ui/Feedback.tsx
new file mode 100644
index 0000000..27f360b
--- /dev/null
+++ b/frontend/src/components/ui/Feedback.tsx
@@ -0,0 +1,115 @@
+/** Ladeskelette, Leerzustände, Abzeichen und Bestätigungsdialog. */
+
+import { type ReactNode } from "react";
+
+import { AlertTriangle, type LucideIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/Button";
+import { Modal } from "@/components/ui/Modal";
+import { cn } from "@/lib/cn";
+
+export function Skeleton({ className }: { className?: string }) {
+ return ;
+}
+
+export function TableSkeleton({ rows = 5 }: { rows?: number }) {
+ return (
+
+ {Array.from({ length: rows }, (_, index) => (
+
+ ))}
+
+ );
+}
+
+export interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+ action?: ReactNode;
+}
+
+export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
+ return (
+
+
+
{title}
+
{description}
+ {action &&
{action}
}
+
+ );
+}
+
+type BadgeTone = "neutral" | "accent" | "positive" | "negative" | "warning";
+
+const TOENE: Record = {
+ neutral: "bg-raised text-muted",
+ accent: "bg-accent/15 text-accent",
+ positive: "bg-positive/15 text-positive",
+ negative: "bg-negative/15 text-negative",
+ warning: "bg-warning/15 text-warning",
+};
+
+export function Badge({
+ children,
+ tone = "neutral",
+ className,
+}: {
+ children: ReactNode;
+ tone?: BadgeTone;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface ConfirmDialogProps {
+ open: boolean;
+ title: string;
+ description: string;
+ confirmLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ loading?: boolean;
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel = "Löschen",
+ onConfirm,
+ onCancel,
+ loading,
+}: ConfirmDialogProps) {
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Field.tsx b/frontend/src/components/ui/Field.tsx
new file mode 100644
index 0000000..9bcbe51
--- /dev/null
+++ b/frontend/src/components/ui/Field.tsx
@@ -0,0 +1,90 @@
+/** Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. */
+
+import {
+ type InputHTMLAttributes,
+ type ReactNode,
+ type SelectHTMLAttributes,
+ type TextareaHTMLAttributes,
+ forwardRef,
+ useId,
+} from "react";
+
+import { cn } from "@/lib/cn";
+
+const EINGABE_KLASSEN =
+ "w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink " +
+ "placeholder:text-faint transition focus:border-accent disabled:opacity-60";
+
+export interface FieldProps {
+ label: string;
+ hint?: string;
+ error?: string | null;
+ required?: boolean;
+ children: (id: string) => ReactNode;
+ className?: string;
+}
+
+export function Field({ label, hint, error, required, children, className }: FieldProps) {
+ const id = useId();
+ return (
+
+
+ {children(id)}
+ {error ? (
+
{error}
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
+
+export const Input = forwardRef>(
+ function Input({ className, ...rest }, ref) {
+ return ;
+ },
+);
+
+export const Select = forwardRef>(
+ function Select({ className, children, ...rest }, ref) {
+ return (
+
+ );
+ },
+);
+
+export const Textarea = forwardRef>(
+ function Textarea({ className, ...rest }, ref) {
+ return ;
+ },
+);
+
+export interface CheckboxProps extends Omit, "type"> {
+ label: string;
+ hint?: string;
+}
+
+export function Checkbox({ label, hint, className, ...rest }: CheckboxProps) {
+ const id = useId();
+ return (
+
+
+
+
+ {hint &&
{hint}
}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..faa6a11
--- /dev/null
+++ b/frontend/src/components/ui/Modal.tsx
@@ -0,0 +1,111 @@
+/** Modaler Dialog mit Fokusfalle und Schließen per Escape. */
+
+import { type ReactNode, useCallback, useEffect, useRef } from "react";
+
+import { X } from "lucide-react";
+
+import { Button } from "@/components/ui/Button";
+import { cn } from "@/lib/cn";
+
+export interface ModalProps {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ description?: string;
+ children: ReactNode;
+ footer?: ReactNode;
+ size?: "sm" | "md" | "lg";
+}
+
+const BREITEN = { sm: "max-w-md", md: "max-w-2xl", lg: "max-w-4xl" };
+
+export function Modal({
+ open,
+ onClose,
+ title,
+ description,
+ children,
+ footer,
+ size = "md",
+}: ModalProps) {
+ const dialog = useRef(null);
+
+ const tastatur = useCallback(
+ (ereignis: KeyboardEvent) => {
+ if (ereignis.key === "Escape") {
+ onClose();
+ return;
+ }
+ if (ereignis.key !== "Tab" || !dialog.current) return;
+
+ // Fokus im Dialog halten.
+ const fokussierbar = dialog.current.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ );
+ const erstes = fokussierbar[0];
+ const letztes = fokussierbar[fokussierbar.length - 1];
+ if (!erstes || !letztes) return;
+
+ if (ereignis.shiftKey && document.activeElement === erstes) {
+ ereignis.preventDefault();
+ letztes.focus();
+ } else if (!ereignis.shiftKey && document.activeElement === letztes) {
+ ereignis.preventDefault();
+ erstes.focus();
+ }
+ },
+ [onClose],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+ document.addEventListener("keydown", tastatur);
+ const vorher = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ // Erstes Eingabefeld bekommt den Fokus.
+ const ziel = dialog.current?.querySelector("input, select, textarea, button");
+ ziel?.focus();
+ return () => {
+ document.removeEventListener("keydown", tastatur);
+ document.body.style.overflow = vorher;
+ };
+ }, [open, tastatur]);
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+
+
+
{children}
+
+ {footer && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/MoneyInput.tsx b/frontend/src/components/ui/MoneyInput.tsx
new file mode 100644
index 0000000..808a3f9
--- /dev/null
+++ b/frontend/src/components/ui/MoneyInput.tsx
@@ -0,0 +1,49 @@
+/**
+ * Betragsfeld in deutscher Schreibweise.
+ *
+ * Nach außen fließt immer das API-Format ("13.99"), angezeigt wird "13,99".
+ */
+
+import { type InputHTMLAttributes, useEffect, useState } from "react";
+
+import { Input } from "@/components/ui/Field";
+import { parseAmountInput, toAmountInput } from "@/lib/format";
+
+export interface MoneyInputProps
+ extends Omit, "value" | "onChange" | "type"> {
+ value: string;
+ onValueChange: (value: string) => void;
+}
+
+export function MoneyInput({ value, onValueChange, ...rest }: MoneyInputProps) {
+ const [text, setText] = useState(() => toAmountInput(value));
+
+ // Von außen gesetzte Werte übernehmen, solange der Nutzer nicht gerade tippt.
+ useEffect(() => {
+ const normalisiert = parseAmountInput(text);
+ if (normalisiert !== value) setText(toAmountInput(value));
+ // Absichtlich nur auf `value` reagieren.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ return (
+
+ {
+ const eingabe = ereignis.target.value;
+ setText(eingabe);
+ onValueChange(parseAmountInput(eingabe) ?? "");
+ }}
+ onBlur={() => setText(toAmountInput(parseAmountInput(text) ?? ""))}
+ className="pr-8 tabular"
+ {...rest}
+ />
+
+ €
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
new file mode 100644
index 0000000..1fcfa43
--- /dev/null
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -0,0 +1,59 @@
+/** Zeigt die Meldungen aus dem Toast-Store an. */
+
+import { CheckCircle2, Info, X, XCircle } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+import { type ToastKind, useToastStore } from "@/store/toast";
+
+const SYMBOLE = { success: CheckCircle2, error: XCircle, info: Info };
+
+const RAHMEN: Record = {
+ success: "border-positive/40 text-positive",
+ error: "border-negative/40 text-negative",
+ info: "border-line text-muted",
+};
+
+export function Toaster() {
+ const toasts = useToastStore((zustand) => zustand.toasts);
+ const dismiss = useToastStore((zustand) => zustand.dismiss);
+
+ if (toasts.length === 0) return null;
+
+ return (
+
+ {toasts.map((eintrag) => {
+ const Symbol = SYMBOLE[eintrag.kind];
+ return (
+
+
+
+
{eintrag.title}
+ {eintrag.description && (
+
{eintrag.description}
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 0000000..42af832
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,69 @@
+/** Anmeldung, Abmeldung und der angemeldete Benutzer. */
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { ApiError, api, isUnauthorized } from "@/lib/api";
+import { keys } from "@/lib/queryClient";
+import { toast } from "@/store/toast";
+import type { MessageResponse, User } from "@/types/api";
+
+export function useMe() {
+ return useQuery({
+ queryKey: keys.me,
+ queryFn: async () => {
+ try {
+ return await api.get("/me");
+ } catch (fehler) {
+ // Kein gültiges Token bedeutet schlicht: nicht angemeldet.
+ if (isUnauthorized(fehler)) return null;
+ throw fehler;
+ }
+ },
+ staleTime: 60_000,
+ retry: false,
+ });
+}
+
+export function useLogin() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (daten: { username: string; password: string }) =>
+ api.post("/auth/login", daten),
+ onSuccess: (benutzer) => {
+ client.setQueryData(keys.me, benutzer);
+ void client.invalidateQueries();
+ },
+ });
+}
+
+export function useLogout() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.post("/auth/logout"),
+ onSuccess: () => {
+ client.setQueryData(keys.me, null);
+ client.clear();
+ toast.info("Abgemeldet.");
+ },
+ });
+}
+
+export function useChangePassword() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (daten: { current_password: string; new_password: string }) =>
+ api.post("/auth/change-password", daten),
+ onSuccess: () => {
+ // Der Passwortwechsel beendet alle Sitzungen – es folgt eine neue Anmeldung.
+ client.setQueryData(keys.me, null);
+ client.clear();
+ toast.success("Passwort geändert.", "Bitte melde dich neu an.");
+ },
+ });
+}
+
+/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
+export function loginErrorMessage(error: unknown): string {
+ if (error instanceof ApiError) return error.message;
+ return "Der Server ist nicht erreichbar.";
+}
diff --git a/frontend/src/hooks/useCategoryLookup.ts b/frontend/src/hooks/useCategoryLookup.ts
new file mode 100644
index 0000000..b980af4
--- /dev/null
+++ b/frontend/src/hooks/useCategoryLookup.ts
@@ -0,0 +1,17 @@
+/** Liefert zu einer Kategorie-ID den vollständigen Namen inklusive Oberkategorie. */
+
+import { useCategoryTree } from "@/hooks/useEntities";
+
+export function useCategoryLookup(): (id: number) => string {
+ const { data: baum = [] } = useCategoryTree();
+
+ const namen = new Map();
+ for (const oberkategorie of baum) {
+ namen.set(oberkategorie.id, oberkategorie.name);
+ for (const unterkategorie of oberkategorie.children) {
+ namen.set(unterkategorie.id, `${oberkategorie.name} · ${unterkategorie.name}`);
+ }
+ }
+
+ return (id: number) => namen.get(id) ?? "Unbekannt";
+}
diff --git a/frontend/src/hooks/useEntities.ts b/frontend/src/hooks/useEntities.ts
new file mode 100644
index 0000000..095561b
--- /dev/null
+++ b/frontend/src/hooks/useEntities.ts
@@ -0,0 +1,405 @@
+/**
+ * Abfragen und Mutationen der Stammdaten.
+ *
+ * Alle Mutationen invalidieren die betroffenen Schlüssel und melden Erfolg per
+ * Toast; Fehler übernimmt der zentrale Query-Client.
+ */
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { api } from "@/lib/api";
+import { keys } from "@/lib/queryClient";
+import { toast } from "@/store/toast";
+import type {
+ Account,
+ AccountBalance,
+ AccountInput,
+ AmountVersion,
+ Category,
+ CategoryInput,
+ CategoryTree,
+ LogoSearchResult,
+ Merchant,
+ MerchantInput,
+ MessageResponse,
+ Occurrence,
+ OccurrenceConfirmInput,
+ Page,
+ Recurrence,
+ RecurrenceDetail,
+ RecurrenceInput,
+ Transaction,
+ TransactionInput,
+} from "@/types/api";
+
+/* --- Konten --------------------------------------------------------------- */
+
+export function useAccounts(onlyActive = false) {
+ return useQuery({
+ queryKey: [...keys.accounts, onlyActive],
+ queryFn: () => api.get("/accounts", onlyActive ? { is_active: true } : undefined),
+ });
+}
+
+export function useAccountBalance(accountId: number | null, asOf?: string) {
+ return useQuery({
+ queryKey: keys.accountBalance(accountId ?? 0, asOf),
+ queryFn: () =>
+ api.get(`/accounts/${accountId}/balance`, asOf ? { as_of: asOf } : undefined),
+ enabled: accountId !== null,
+ });
+}
+
+export function useSaveAccount() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, daten }: { id?: number; daten: AccountInput | Partial }) =>
+ id ? api.patch(`/accounts/${id}`, daten) : api.post("/accounts", daten),
+ onSuccess: (_konto, variablen) => {
+ void client.invalidateQueries({ queryKey: keys.accounts });
+ toast.success(variablen.id ? "Konto gespeichert." : "Konto angelegt.");
+ },
+ });
+}
+
+export function useDeleteAccount() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.del(`/accounts/${id}`),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: keys.accounts });
+ toast.success("Konto gelöscht.");
+ },
+ });
+}
+
+/* --- Kategorien ----------------------------------------------------------- */
+
+export function useCategoryTree() {
+ return useQuery({
+ queryKey: keys.categories,
+ queryFn: () => api.get("/categories"),
+ staleTime: 5 * 60_000,
+ });
+}
+
+export function useCategoriesFlat() {
+ return useQuery({
+ queryKey: keys.categoriesFlat,
+ queryFn: () => api.get("/categories/flat"),
+ staleTime: 5 * 60_000,
+ });
+}
+
+export function useSaveCategory() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, daten }: { id?: number; daten: CategoryInput | Partial }) =>
+ id
+ ? api.patch(`/categories/${id}`, daten)
+ : api.post("/categories", daten),
+ onSuccess: (_kategorie, variablen) => {
+ void client.invalidateQueries({ queryKey: keys.categories });
+ toast.success(variablen.id ? "Kategorie gespeichert." : "Kategorie angelegt.");
+ },
+ });
+}
+
+export function useDeleteCategory() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.del(`/categories/${id}`),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: keys.categories });
+ toast.success("Kategorie gelöscht.");
+ },
+ });
+}
+
+/* --- Firmen --------------------------------------------------------------- */
+
+export function useMerchants(query?: string) {
+ return useQuery({
+ queryKey: keys.merchants(query),
+ queryFn: () => api.get>("/merchants", { q: query, limit: 200 }),
+ });
+}
+
+export function useSaveMerchant() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, daten }: { id?: number; daten: MerchantInput | Partial }) =>
+ id ? api.patch(`/merchants/${id}`, daten) : api.post("/merchants", daten),
+ onSuccess: (_firma, variablen) => {
+ void client.invalidateQueries({ queryKey: ["merchants"] });
+ toast.success(variablen.id ? "Firma gespeichert." : "Firma angelegt.");
+ },
+ });
+}
+
+export function useDeleteMerchant() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.del(`/merchants/${id}`),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["merchants"] });
+ void client.invalidateQueries({ queryKey: ["transactions"] });
+ toast.success("Firma gelöscht.");
+ },
+ });
+}
+
+export function useLogoSearch() {
+ return useMutation({
+ mutationFn: ({ merchantId, domain }: { merchantId: number; domain?: string }) =>
+ api.post(
+ `/merchants/${merchantId}/logo/search`,
+ undefined,
+ domain ? { domain } : undefined,
+ ),
+ });
+}
+
+export function useSelectLogo() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ merchantId, candidateId }: { merchantId: number; candidateId: number }) =>
+ api.post(`/merchants/${merchantId}/logo/select`, { candidate_id: candidateId }),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["merchants"] });
+ toast.success("Logo übernommen.");
+ },
+ });
+}
+
+export function useUploadLogo() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ merchantId, file }: { merchantId: number; file: File }) => {
+ const daten = new FormData();
+ daten.append("file", file);
+ return api.upload(`/merchants/${merchantId}/logo/upload`, daten);
+ },
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["merchants"] });
+ toast.success("Logo hochgeladen.");
+ },
+ });
+}
+
+/* --- Wiederkehrende Posten ------------------------------------------------ */
+
+export interface RecurrenceFilter {
+ [schluessel: string]: string | number | boolean | undefined;
+ kind?: string;
+ active?: boolean;
+ category_id?: number;
+ account_id?: number;
+ merchant_id?: number;
+}
+
+export function useRecurrences(filter: RecurrenceFilter = {}) {
+ return useQuery({
+ queryKey: keys.recurrences(filter),
+ queryFn: () => api.get("/recurrences", filter),
+ });
+}
+
+export function useRecurrence(id: number | null) {
+ return useQuery({
+ queryKey: keys.recurrence(id ?? 0),
+ queryFn: () => api.get(`/recurrences/${id}`),
+ enabled: id !== null,
+ });
+}
+
+export function useRecurrencePreview(id: number | null, from: string, to: string) {
+ return useQuery({
+ queryKey: keys.recurrencePreview(id ?? 0, from, to),
+ queryFn: () => api.get(`/recurrences/${id}/preview`, { from, to }),
+ enabled: id !== null,
+ });
+}
+
+export function useSaveRecurrence() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, daten }: { id?: number; daten: RecurrenceInput | Partial }) =>
+ id
+ ? api.patch(`/recurrences/${id}`, daten)
+ : api.post("/recurrences", daten),
+ onSuccess: (_posten, variablen) => {
+ void client.invalidateQueries({ queryKey: ["recurrences"] });
+ void client.invalidateQueries({ queryKey: ["occurrences"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ toast.success(variablen.id ? "Posten gespeichert." : "Posten angelegt.");
+ },
+ });
+}
+
+export function useDeleteRecurrence() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.del(`/recurrences/${id}`),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["recurrences"] });
+ void client.invalidateQueries({ queryKey: ["occurrences"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ toast.success("Posten gelöscht.");
+ },
+ });
+}
+
+export function useAddAmountVersion() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ recurrenceId,
+ daten,
+ }: {
+ recurrenceId: number;
+ daten: { amount: string; valid_from: string; note?: string | null };
+ }) => api.post(`/recurrences/${recurrenceId}/amount-versions`, daten),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["recurrences"] });
+ void client.invalidateQueries({ queryKey: ["occurrences"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ toast.success("Preisversion angelegt.");
+ },
+ });
+}
+
+export function useCancelContract() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, effectiveDate }: { id: number; effectiveDate?: string }) =>
+ api.post(
+ `/recurrences/${id}/cancel`,
+ undefined,
+ effectiveDate ? { effective_date: effectiveDate } : undefined,
+ ),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["recurrences"] });
+ void client.invalidateQueries({ queryKey: ["occurrences"] });
+ toast.success("Kündigung vermerkt.");
+ },
+ });
+}
+
+/* --- Fälligkeiten --------------------------------------------------------- */
+
+export interface OccurrenceFilter {
+ [schluessel: string]: string | number | undefined;
+ from: string;
+ to: string;
+ kind?: string;
+ category_id?: number;
+ account_id?: number;
+ status?: string;
+}
+
+export function useOccurrences(filter: OccurrenceFilter) {
+ return useQuery({
+ queryKey: keys.occurrences(filter),
+ queryFn: () => api.get("/occurrences", { ...filter }),
+ });
+}
+
+function occurrenceInvalidation(client: ReturnType): void {
+ void client.invalidateQueries({ queryKey: ["occurrences"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ void client.invalidateQueries({ queryKey: keys.accounts });
+}
+
+export function useConfirmOccurrence() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (daten: OccurrenceConfirmInput) =>
+ api.post("/occurrences/confirm", daten),
+ onSuccess: () => {
+ occurrenceInvalidation(client);
+ toast.success("Fälligkeit bestätigt.");
+ },
+ });
+}
+
+export function useSkipOccurrence() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (daten: { recurrence_id: number; occurrence_date: string; note?: string }) =>
+ api.post("/occurrences/skip", daten),
+ onSuccess: () => {
+ occurrenceInvalidation(client);
+ toast.success("Fälligkeit ausgelassen.");
+ },
+ });
+}
+
+export function useResetOccurrence() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (daten: { recurrence_id: number; occurrence_date: string }) =>
+ api.post("/occurrences/reset", daten),
+ onSuccess: () => {
+ occurrenceInvalidation(client);
+ toast.success("Zurückgesetzt.");
+ },
+ });
+}
+
+/* --- Buchungen ------------------------------------------------------------ */
+
+export interface TransactionFilter {
+ [schluessel: string]: string | number | undefined;
+ from?: string;
+ to?: string;
+ kind?: string;
+ category_id?: number;
+ account_id?: number;
+ merchant_id?: number;
+ q?: string;
+ limit?: number;
+ offset?: number;
+}
+
+export function useTransactions(filter: TransactionFilter = {}) {
+ return useQuery({
+ queryKey: keys.transactions(filter),
+ queryFn: () => api.get>("/transactions", { ...filter }),
+ });
+}
+
+export function useSaveTransaction() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ id,
+ daten,
+ }: {
+ id?: number;
+ daten: TransactionInput | Partial;
+ }) =>
+ id
+ ? api.patch(`/transactions/${id}`, daten)
+ : api.post("/transactions", daten),
+ onSuccess: (_buchung, variablen) => {
+ void client.invalidateQueries({ queryKey: ["transactions"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ void client.invalidateQueries({ queryKey: keys.accounts });
+ toast.success(variablen.id ? "Buchung gespeichert." : "Buchung angelegt.");
+ },
+ });
+}
+
+export function useDeleteTransaction() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.del(`/transactions/${id}`),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["transactions"] });
+ void client.invalidateQueries({ queryKey: ["reports"] });
+ void client.invalidateQueries({ queryKey: keys.accounts });
+ toast.success("Buchung gelöscht.");
+ },
+ });
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..7021886
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,73 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ /* Heller Modus */
+ :root {
+ --color-ground: 248 250 252;
+ --color-surface: 255 255 255;
+ --color-raised: 241 245 249;
+ --color-line: 226 232 240;
+ --color-ink: 15 23 42;
+ --color-muted: 71 85 105;
+ --color-faint: 148 163 184;
+ --color-accent: 22 163 74;
+ --color-accent-ink: 255 255 255;
+ --color-positive: 21 128 61;
+ --color-negative: 190 18 60;
+ --color-warning: 180 83 9;
+ }
+
+ /* Dunkler Modus – die Vorgabe der Anwendung */
+ .dark {
+ --color-ground: 15 17 21;
+ --color-surface: 24 27 33;
+ --color-raised: 33 37 45;
+ --color-line: 51 57 68;
+ --color-ink: 226 232 240;
+ --color-muted: 148 163 184;
+ --color-faint: 100 116 139;
+ --color-accent: 34 197 94;
+ --color-accent-ink: 4 20 11;
+ --color-positive: 74 222 128;
+ --color-negative: 248 113 113;
+ --color-warning: 251 191 36;
+ }
+
+ html {
+ @apply h-full;
+ }
+
+ body {
+ @apply h-full bg-ground font-sans text-ink antialiased;
+ }
+
+ #root {
+ @apply h-full;
+ }
+
+ /* Sichtbarer Fokus überall – die Anwendung ist vollständig per Tastatur bedienbar. */
+ :focus-visible {
+ @apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-ground;
+ }
+
+ ::-webkit-scrollbar {
+ @apply h-2 w-2;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply rounded-full bg-line;
+ }
+}
+
+@layer components {
+ .card {
+ @apply rounded-card border border-line bg-surface;
+ }
+
+ /* Zahlen sollen untereinander bündig stehen. */
+ .tabular {
+ font-variant-numeric: tabular-nums;
+ }
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..62105f6
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,158 @@
+/**
+ * Schmaler Client um `fetch`.
+ *
+ * Cookies fahren immer mit, Fehler kommen als `ApiError` mit dem Fehlercode des
+ * Backends zurück. Läuft ein Zugriff in ein abgelaufenes Access-Token, wird
+ * einmalig die Sitzung erneuert und der Aufruf wiederholt.
+ */
+
+import type { ProblemDetail } from "@/types/api";
+
+export const API_BASE = "/api";
+
+export class ApiError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly problem: ProblemDetail;
+
+ constructor(status: number, problem: ProblemDetail) {
+ super(problem.detail);
+ this.name = "ApiError";
+ this.status = status;
+ this.code = problem.code;
+ this.problem = problem;
+ }
+
+ /** Erste Feldmeldung einer Eingabeprüfung, falls vorhanden. */
+ get fieldMessage(): string | null {
+ const erster = this.problem.errors?.[0];
+ if (!erster) return null;
+ const feld = erster.loc.filter((teil) => teil !== "body").join(".");
+ return feld ? `${feld}: ${erster.msg}` : erster.msg;
+ }
+}
+
+/** Wird ausgelöst, wenn keine gültige Sitzung besteht. */
+export function isUnauthorized(error: unknown): boolean {
+ return error instanceof ApiError && error.status === 401;
+}
+
+/** Der Passwortwechsel steht noch aus und blockiert alle Fachrouten. */
+export function isPasswordChangeRequired(error: unknown): boolean {
+ return error instanceof ApiError && error.code === "password_change_required";
+}
+
+export type QueryValue = string | number | boolean | null | undefined;
+
+export function buildUrl(path: string, params?: Record): string {
+ const url = `${API_BASE}${path}`;
+ if (!params) return url;
+
+ const suchparameter = new URLSearchParams();
+ for (const [schluessel, wert] of Object.entries(params)) {
+ if (wert === null || wert === undefined || wert === "") continue;
+ suchparameter.set(schluessel, String(wert));
+ }
+ const abfrage = suchparameter.toString();
+ return abfrage ? `${url}?${abfrage}` : url;
+}
+
+interface RequestOptions {
+ method?: string;
+ body?: unknown;
+ params?: Record;
+ /** Multipart-Inhalt; `body` bleibt dann leer. */
+ formData?: FormData;
+ signal?: AbortSignal;
+}
+
+async function parseProblem(response: Response): Promise {
+ try {
+ const daten = (await response.json()) as Partial;
+ return {
+ detail: daten.detail ?? "Unbekannter Fehler.",
+ code: daten.code ?? "error",
+ errors: daten.errors,
+ };
+ } catch {
+ return { detail: `Der Server antwortete mit Status ${response.status}.`, code: "error" };
+ }
+}
+
+async function raw(path: string, options: RequestOptions): Promise {
+ const kopfzeilen: Record = { Accept: "application/json" };
+ let inhalt: BodyInit | undefined;
+
+ if (options.formData) {
+ // Den Content-Type setzt der Browser samt boundary selbst.
+ inhalt = options.formData;
+ } else if (options.body !== undefined) {
+ kopfzeilen["Content-Type"] = "application/json";
+ inhalt = JSON.stringify(options.body);
+ }
+
+ return fetch(buildUrl(path, options.params), {
+ method: options.method ?? "GET",
+ headers: kopfzeilen,
+ body: inhalt,
+ credentials: "same-origin",
+ signal: options.signal,
+ });
+}
+
+/** Endpunkte, bei denen ein 401 nicht durch eine Token-Erneuerung heilbar ist. */
+const KEINE_ERNEUERUNG = ["/auth/login", "/auth/refresh", "/auth/logout"];
+
+let laufendeErneuerung: Promise | null = null;
+
+async function erneuereSitzung(): Promise {
+ // Mehrere gleichzeitig fehlschlagende Abfragen teilen sich einen Versuch.
+ laufendeErneuerung ??= (async () => {
+ try {
+ const antwort = await raw("/auth/refresh", { method: "POST" });
+ return antwort.ok;
+ } catch {
+ return false;
+ } finally {
+ // Erst im nächsten Mikrotask zurücksetzen, damit Wartende dasselbe Ergebnis sehen.
+ queueMicrotask(() => {
+ laufendeErneuerung = null;
+ });
+ }
+ })();
+ return laufendeErneuerung;
+}
+
+export async function request(path: string, options: RequestOptions = {}): Promise {
+ let antwort = await raw(path, options);
+
+ if (antwort.status === 401 && !KEINE_ERNEUERUNG.some((pfad) => path.startsWith(pfad))) {
+ if (await erneuereSitzung()) {
+ antwort = await raw(path, options);
+ }
+ }
+
+ if (!antwort.ok) {
+ throw new ApiError(antwort.status, await parseProblem(antwort));
+ }
+
+ if (antwort.status === 204 || antwort.headers.get("content-length") === "0") {
+ return undefined as T;
+ }
+ return (await antwort.json()) as T;
+}
+
+export const api = {
+ get: (path: string, params?: Record) => request(path, { params }),
+ post: (path: string, body?: unknown, params?: Record) =>
+ request(path, { method: "POST", body, params }),
+ patch: (path: string, body: unknown) => request(path, { method: "PATCH", body }),
+ del: (path: string) => request(path, { method: "DELETE" }),
+ upload: (path: string, formData: FormData) =>
+ request(path, { method: "POST", formData }),
+};
+
+/** Bild-URL eines zwischengespeicherten Logos. */
+export function logoUrl(assetId: number): string {
+ return `${API_BASE}/logos/${assetId}`;
+}
diff --git a/frontend/src/lib/cn.ts b/frontend/src/lib/cn.ts
new file mode 100644
index 0000000..17d125b
--- /dev/null
+++ b/frontend/src/lib/cn.ts
@@ -0,0 +1,4 @@
+/** Fügt Klassennamen zusammen und wirft Leeres weg. */
+export function cn(...classes: (string | false | null | undefined)[]): string {
+ return classes.filter(Boolean).join(" ");
+}
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts
new file mode 100644
index 0000000..317905d
--- /dev/null
+++ b/frontend/src/lib/format.test.ts
@@ -0,0 +1,100 @@
+/** Tests der Formatierung und Betragsumwandlung. */
+
+import { describe, expect, it } from "vitest";
+
+import {
+ addMonthsIso,
+ firstOfMonth,
+ formatDate,
+ formatMoney,
+ formatMonth,
+ formatSignedMoney,
+ parseAmountInput,
+ relativeDays,
+ toAmountInput,
+ toIsoDate,
+ toNumber,
+} from "@/lib/format";
+
+/**
+ * Intl setzt vor das Währungszeichen ein geschütztes Leerzeichen, dessen genaue
+ * Form von der ICU-Version abhängt. Für die Erwartungen wird es vereinheitlicht.
+ */
+function geld(wert: string): string {
+ return wert.replace(/\s/gu, " ");
+}
+
+
+describe("Geldbeträge", () => {
+ it("formatiert nach de-DE mit zwei Nachkommastellen", () => {
+ expect(geld(formatMoney("13.99"))).toBe("13,99 €");
+ expect(geld(formatMoney("1234.5"))).toBe("1.234,50 €");
+ expect(geld(formatMoney("0"))).toBe("0,00 €");
+ expect(geld(formatMoney(-42))).toBe("-42,00 €");
+ });
+
+ it("zeigt auf Wunsch auch ein Pluszeichen", () => {
+ expect(geld(formatSignedMoney("120"))).toBe("+120,00 €");
+ expect(geld(formatSignedMoney("-120"))).toBe("-120,00 €");
+ });
+
+ it("behandelt fehlende Werte als null", () => {
+ expect(toNumber(null)).toBe(0);
+ expect(toNumber(undefined)).toBe(0);
+ expect(toNumber("")).toBe(0);
+ expect(toNumber("keine zahl")).toBe(0);
+ });
+
+ it("liest deutsche und englische Schreibweise", () => {
+ expect(parseAmountInput("13,99")).toBe("13.99");
+ expect(parseAmountInput("13.99")).toBe("13.99");
+ expect(parseAmountInput("1.234,56")).toBe("1234.56");
+ expect(parseAmountInput(" 12 € ")).toBe("12.00");
+ expect(parseAmountInput("")).toBeNull();
+ expect(parseAmountInput("abc")).toBeNull();
+ });
+
+ it("stellt Beträge fürs Eingabefeld dar", () => {
+ expect(toAmountInput("13.99")).toBe("13,99");
+ expect(toAmountInput("5")).toBe("5,00");
+ expect(toAmountInput(null)).toBe("");
+ });
+
+ it("überlebt den Weg Eingabe → API → Eingabe", () => {
+ expect(toAmountInput(parseAmountInput("1.234,56")!)).toBe("1234,56");
+ expect(toAmountInput(parseAmountInput("13,99")!)).toBe("13,99");
+ });
+});
+
+describe("Datumsangaben", () => {
+ it("formatiert im deutschen Format", () => {
+ expect(formatDate("2026-03-01")).toBe("01.03.2026");
+ expect(formatDate(null)).toBe("–");
+ expect(formatMonth("2026-03-15")).toBe("März 2026");
+ });
+
+ it("liest reine Datumsangaben als lokalen Tag", () => {
+ // Ohne diese Behandlung könnte je nach Zeitzone der Vortag erscheinen.
+ expect(formatDate("2026-01-01")).toBe("01.01.2026");
+ });
+
+ it("rechnet mit Monaten und Monatsgrenzen", () => {
+ expect(firstOfMonth("2026-03-17")).toBe("2026-03-01");
+ expect(addMonthsIso("2026-01-31", 1)).toBe("2026-02-28");
+ expect(addMonthsIso("2026-03-15", -1)).toBe("2026-02-15");
+ expect(addMonthsIso("2026-01-15", 12)).toBe("2027-01-15");
+ });
+
+ it("wandelt ein Datum verlustfrei in ISO", () => {
+ expect(toIsoDate(new Date(2026, 5, 4))).toBe("2026-06-04");
+ });
+
+ it("beschreibt Abstände in Tagen", () => {
+ const heute = new Date(2026, 2, 10);
+ expect(relativeDays("2026-03-10", heute)).toBe("heute");
+ expect(relativeDays("2026-03-11", heute)).toBe("morgen");
+ expect(relativeDays("2026-03-09", heute)).toBe("gestern");
+ expect(relativeDays("2026-03-15", heute)).toBe("in 5 Tagen");
+ expect(relativeDays("2026-03-01", heute)).toBe("vor 9 Tagen");
+ });
+});
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..a7ae01c
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -0,0 +1,144 @@
+/** Formatierung und Rechnen mit Geldbeträgen, durchgängig in de-DE. */
+
+const WAEHRUNG = new Intl.NumberFormat("de-DE", {
+ style: "currency",
+ currency: "EUR",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const WAEHRUNG_MIT_VORZEICHEN = new Intl.NumberFormat("de-DE", {
+ style: "currency",
+ currency: "EUR",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ signDisplay: "exceptZero",
+});
+
+const ZAHL = new Intl.NumberFormat("de-DE", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const DATUM = new Intl.DateTimeFormat("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+});
+
+const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+});
+
+const MONAT = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" });
+const WOCHENTAG = new Intl.DateTimeFormat("de-DE", { weekday: "short" });
+
+/** Wandelt einen Betrag der API (String) in eine Zahl. */
+export function toNumber(amount: string | number | null | undefined): number {
+ if (amount === null || amount === undefined || amount === "") return 0;
+ const zahl = typeof amount === "number" ? amount : Number.parseFloat(amount);
+ return Number.isFinite(zahl) ? zahl : 0;
+}
+
+export function formatMoney(amount: string | number | null | undefined): string {
+ return WAEHRUNG.format(toNumber(amount));
+}
+
+/** Wie `formatMoney`, stellt aber auch bei positiven Werten ein Vorzeichen voran. */
+export function formatSignedMoney(amount: string | number | null | undefined): string {
+ return WAEHRUNG_MIT_VORZEICHEN.format(toNumber(amount));
+}
+
+export function formatNumber(value: number): string {
+ return ZAHL.format(value);
+}
+
+/**
+ * Bringt eine Nutzereingabe auf das Format der API.
+ * Akzeptiert "12,50", "12.50" und "1.234,56".
+ */
+export function parseAmountInput(input: string): string | null {
+ const bereinigt = input.trim().replace(/\s|€/g, "");
+ if (!bereinigt) return null;
+
+ // Deutsche Schreibweise: Punkt trennt Tausender, Komma die Nachkommastellen.
+ const normalisiert = bereinigt.includes(",")
+ ? bereinigt.replace(/\./g, "").replace(",", ".")
+ : bereinigt;
+
+ const zahl = Number.parseFloat(normalisiert);
+ if (!Number.isFinite(zahl)) return null;
+ return zahl.toFixed(2);
+}
+
+/** Zeigt einen API-Betrag in einem Eingabefeld an ("13.99" -> "13,99"). */
+export function toAmountInput(amount: string | number | null | undefined): string {
+ if (amount === null || amount === undefined || amount === "") return "";
+ return toNumber(amount).toFixed(2).replace(".", ",");
+}
+
+function toDate(value: string | Date): Date {
+ if (value instanceof Date) return value;
+ // Reine Datumsangaben ohne Zeitzone werden als lokaler Tag gelesen.
+ const [jahr, monat, tag] = value.split("-").map(Number);
+ if (jahr && monat && tag) return new Date(jahr, monat - 1, tag);
+ return new Date(value);
+}
+
+export function formatDate(value: string | Date | null | undefined): string {
+ if (!value) return "–";
+ return DATUM.format(toDate(value));
+}
+
+export function formatDateLong(value: string | Date | null | undefined): string {
+ if (!value) return "–";
+ return DATUM_LANG.format(toDate(value));
+}
+
+export function formatMonth(value: string | Date | null | undefined): string {
+ if (!value) return "–";
+ return MONAT.format(toDate(value));
+}
+
+export function formatWeekday(value: string | Date): string {
+ return WOCHENTAG.format(toDate(value));
+}
+
+/** ISO-Datum (YYYY-MM-DD) eines Datums in lokaler Zeit. */
+export function toIsoDate(date: Date): string {
+ const monat = String(date.getMonth() + 1).padStart(2, "0");
+ const tag = String(date.getDate()).padStart(2, "0");
+ return `${date.getFullYear()}-${monat}-${tag}`;
+}
+
+export function todayIso(): string {
+ return toIsoDate(new Date());
+}
+
+export function firstOfMonth(value: string | Date = new Date()): string {
+ const datum = toDate(value);
+ return toIsoDate(new Date(datum.getFullYear(), datum.getMonth(), 1));
+}
+
+export function addMonthsIso(value: string, months: number): string {
+ const datum = toDate(value);
+ const ziel = new Date(datum.getFullYear(), datum.getMonth() + months, 1);
+ const letzterTag = new Date(ziel.getFullYear(), ziel.getMonth() + 1, 0).getDate();
+ ziel.setDate(Math.min(datum.getDate(), letzterTag));
+ return toIsoDate(ziel);
+}
+
+/** "in 5 Tagen", "heute", "vor 2 Tagen" – für Fälligkeitshinweise. */
+export function relativeDays(value: string, reference: Date = new Date()): string {
+ const ziel = toDate(value);
+ const heute = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
+ const tage = Math.round((ziel.getTime() - heute.getTime()) / 86_400_000);
+
+ if (tage === 0) return "heute";
+ if (tage === 1) return "morgen";
+ if (tage === -1) return "gestern";
+ if (tage > 0) return `in ${tage} Tagen`;
+ return `vor ${Math.abs(tage)} Tagen`;
+}
diff --git a/frontend/src/lib/initials.ts b/frontend/src/lib/initials.ts
new file mode 100644
index 0000000..f8b26cc
--- /dev/null
+++ b/frontend/src/lib/initials.ts
@@ -0,0 +1,10 @@
+/** Ein bis zwei Initialen eines Namens – Ersatz, wenn kein Logo vorliegt. */
+export function initialsOf(name: string): string {
+ const woerter = name
+ .split(/[\s\-_/]+/)
+ .map((wort) => wort.replace(/[^0-9A-Za-zÄÖÜäöüß]/g, ""))
+ .filter(Boolean);
+ if (woerter.length === 0) return "?";
+ if (woerter.length === 1) return woerter[0]!.slice(0, 2).toUpperCase();
+ return (woerter[0]![0]! + woerter[1]![0]!).toUpperCase();
+}
diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts
new file mode 100644
index 0000000..4764757
--- /dev/null
+++ b/frontend/src/lib/queryClient.ts
@@ -0,0 +1,71 @@
+/**
+ * Gemeinsamer Query-Client.
+ *
+ * Fehlgeschlagene Abfragen und Mutationen melden sich zentral über einen Toast,
+ * damit nicht jede Seite ihre eigene Fehlerbehandlung braucht. Fehlende
+ * Anmeldung ist davon ausgenommen – darauf reagiert die Anmeldelogik.
+ */
+
+import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
+
+import { ApiError, isPasswordChangeRequired, isUnauthorized } from "@/lib/api";
+import { toast } from "@/store/toast";
+
+function beschreibung(error: unknown): { title: string; description?: string } {
+ if (error instanceof ApiError) {
+ const feld = error.fieldMessage;
+ return { title: error.message, description: feld ?? undefined };
+ }
+ if (error instanceof Error) {
+ return { title: "Der Server ist nicht erreichbar.", description: error.message };
+ }
+ return { title: "Unbekannter Fehler." };
+}
+
+function melden(error: unknown): void {
+ // Diese beiden Fälle behandelt die Anwendung durch Weiterleitung, nicht per Toast.
+ if (isUnauthorized(error) || isPasswordChangeRequired(error)) return;
+ const { title, description } = beschreibung(error);
+ toast.error(title, description);
+}
+
+export function createQueryClient(): QueryClient {
+ return new QueryClient({
+ queryCache: new QueryCache({ onError: melden }),
+ mutationCache: new MutationCache({ onError: melden }),
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ retry: (anzahl, fehler) => {
+ // Fachliche Fehler wiederholen sich nicht von selbst.
+ if (fehler instanceof ApiError && fehler.status < 500) return false;
+ return anzahl < 2;
+ },
+ },
+ mutations: { retry: false },
+ },
+ });
+}
+
+export const queryClient = createQueryClient();
+
+/** Schlüssel aller Abfragen an einem Ort – so bleibt das Invalidieren überschaubar. */
+export const keys = {
+ me: ["me"] as const,
+ accounts: ["accounts"] as const,
+ accountBalance: (id: number, asOf?: string) => ["accounts", id, "balance", asOf] as const,
+ categories: ["categories"] as const,
+ categoriesFlat: ["categories", "flat"] as const,
+ merchants: (query?: string) => ["merchants", query ?? ""] as const,
+ merchant: (id: number) => ["merchants", id] as const,
+ recurrences: (filter?: Record) => ["recurrences", filter ?? {}] as const,
+ recurrence: (id: number) => ["recurrences", id] as const,
+ recurrencePreview: (id: number, from: string, to: string) =>
+ ["recurrences", id, "preview", from, to] as const,
+ occurrences: (filter?: Record) => ["occurrences", filter ?? {}] as const,
+ transactions: (filter?: Record) => ["transactions", filter ?? {}] as const,
+ budgets: (month?: string) => ["budgets", month ?? ""] as const,
+ goals: ["savings-goals"] as const,
+ monthReport: (month: string) => ["reports", "month", month] as const,
+};
diff --git a/frontend/src/lib/recurrenceMath.test.ts b/frontend/src/lib/recurrenceMath.test.ts
new file mode 100644
index 0000000..cc1b677
--- /dev/null
+++ b/frontend/src/lib/recurrenceMath.test.ts
@@ -0,0 +1,29 @@
+/** Tests der Hochrechnung für Listenanzeigen. */
+
+import { describe, expect, it } from "vitest";
+
+import { annualCostOf, monthlyCostOf, occurrencesPerYear } from "@/lib/recurrenceMath";
+import { LAST_DAY_RULE } from "@/lib/rrule";
+
+describe("Termine pro Jahr", () => {
+ it("rechnet die gängigen Rhythmen", () => {
+ expect(occurrencesPerYear("FREQ=MONTHLY;BYMONTHDAY=1")).toBe(12);
+ expect(occurrencesPerYear("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1")).toBe(4);
+ expect(occurrencesPerYear("FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=1")).toBe(2);
+ expect(occurrencesPerYear("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15")).toBe(1);
+ expect(occurrencesPerYear("FREQ=WEEKLY;BYDAY=MO")).toBe(52);
+ });
+
+ it("zählt BYSETPOS als genau einen Termin je Monat", () => {
+ // Ohne diese Behandlung ergäbe die Regel vier Termine im Monat.
+ expect(occurrencesPerYear(LAST_DAY_RULE)).toBe(12);
+ });
+});
+
+describe("Kostenschätzung", () => {
+ it("rechnet Jahres- und Monatsbetrag", () => {
+ expect(annualCostOf("FREQ=MONTHLY;BYMONTHDAY=1", 13.99)).toBeCloseTo(167.88, 2);
+ expect(monthlyCostOf("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", 612)).toBeCloseTo(51, 2);
+ expect(monthlyCostOf("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", 150)).toBeCloseTo(50, 2);
+ });
+});
diff --git a/frontend/src/lib/recurrenceMath.ts b/frontend/src/lib/recurrenceMath.ts
new file mode 100644
index 0000000..a3beb35
--- /dev/null
+++ b/frontend/src/lib/recurrenceMath.ts
@@ -0,0 +1,46 @@
+/**
+ * Grobe Hochrechnung für Anzeigen im Frontend.
+ *
+ * Maßgeblich bleibt die Berechnung im Backend – `RecurrenceDetail.annual_burden`
+ * ist der genaue Wert. Für Listen und Kacheln, in denen nur die Regel vorliegt,
+ * genügt diese Schätzung aus Frequenz und Intervall.
+ */
+
+import { parseRRule } from "@/lib/rrule";
+
+/** Wie oft eine Regel im Jahr ungefähr auslöst. */
+export function occurrencesPerYear(rrule: string): number {
+ const teile = parseRRule(rrule);
+ const intervall = Math.max(1, Number(teile.INTERVAL ?? "1") || 1);
+
+ switch (teile.FREQ) {
+ case "DAILY":
+ return 365 / intervall;
+ case "WEEKLY": {
+ const tage = (teile.BYDAY ?? "").split(",").filter(Boolean).length || 1;
+ return (52 / intervall) * tage;
+ }
+ case "MONTHLY": {
+ const tage = (teile.BYMONTHDAY ?? "").split(",").filter(Boolean).length;
+ // BYSETPOS wählt aus mehreren Monatstagen genau einen aus.
+ const proMonat = teile.BYSETPOS ? 1 : Math.max(1, tage);
+ return (12 / intervall) * proMonat;
+ }
+ case "YEARLY": {
+ const monate = (teile.BYMONTH ?? "").split(",").filter(Boolean).length || 1;
+ return monate / intervall;
+ }
+ default:
+ return 12;
+ }
+}
+
+/** Geschätzte Jahreskosten eines Postens. */
+export function annualCostOf(rrule: string, amount: number): number {
+ return occurrencesPerYear(rrule) * amount;
+}
+
+/** Geschätzter Monatsbetrag eines Postens. */
+export function monthlyCostOf(rrule: string, amount: number): number {
+ return annualCostOf(rrule, amount) / 12;
+}
diff --git a/frontend/src/lib/rrule.test.ts b/frontend/src/lib/rrule.test.ts
new file mode 100644
index 0000000..cf6d687
--- /dev/null
+++ b/frontend/src/lib/rrule.test.ts
@@ -0,0 +1,127 @@
+/** Tests des RRULE-Aufbaus und der deutschen Beschreibung. */
+
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_PRESET_STATE,
+ LAST_DAY_RULE,
+ buildRRule,
+ describeRRule,
+ parseRRule,
+ presetFromRule,
+ ruleFromPreset,
+} from "@/lib/rrule";
+
+describe("Regeln zerlegen und zusammensetzen", () => {
+ it("liest die Bestandteile", () => {
+ expect(parseRRule("FREQ=MONTHLY;BYMONTHDAY=1")).toEqual({
+ FREQ: "MONTHLY",
+ BYMONTHDAY: "1",
+ });
+ });
+
+ it("schreibt FREQ nach vorne", () => {
+ expect(buildRRule({ BYMONTHDAY: "15", FREQ: "MONTHLY", INTERVAL: "3" })).toBe(
+ "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
+ );
+ });
+});
+
+describe("Vorlagen des Editors", () => {
+ it("erzeugt die erwarteten Regeln", () => {
+ expect(ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "monthly", monthDay: 1 })).toBe(
+ "FREQ=MONTHLY;BYMONTHDAY=1",
+ );
+ expect(
+ ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "quarterly", monthDay: 15 }),
+ ).toBe("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15");
+ expect(
+ ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "everyNMonths", interval: 2, monthDay: 10 }),
+ ).toBe("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10");
+ expect(
+ ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "yearly", month: 1, monthDay: 15 }),
+ ).toBe("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15");
+ expect(
+ ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "weekly", weekdays: ["MO", "TH"], interval: 1 }),
+ ).toBe("FREQ=WEEKLY;BYDAY=MO,TH");
+ expect(ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "lastDayOfMonth" })).toBe(
+ LAST_DAY_RULE,
+ );
+ });
+
+ it("erkennt die Vorlage zu einer bestehenden Regel wieder", () => {
+ expect(presetFromRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-01-01").preset).toBe("monthly");
+ expect(presetFromRule("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15", "2026-01-15").preset).toBe(
+ "quarterly",
+ );
+ expect(presetFromRule("FREQ=MONTHLY;INTERVAL=2", "2026-01-10").preset).toBe("everyNMonths");
+ expect(presetFromRule("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", "2026-01-15").preset).toBe(
+ "yearly",
+ );
+ expect(presetFromRule("FREQ=WEEKLY;BYDAY=MO", "2026-01-05").preset).toBe("weekly");
+ expect(presetFromRule(LAST_DAY_RULE, "2026-01-31").preset).toBe("lastDayOfMonth");
+ });
+
+ it("schickt unbekannte Regeln in den Expertenmodus", () => {
+ const zustand = presetFromRule("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36", "2026-01-01");
+ expect(zustand.preset).toBe("custom");
+ expect(zustand.custom).toBe("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36");
+ });
+
+ it("überlebt den Weg Regel → Vorlage → Regel", () => {
+ for (const regel of [
+ "FREQ=MONTHLY;BYMONTHDAY=1",
+ "FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10",
+ "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4",
+ "FREQ=WEEKLY;BYDAY=MO,TH",
+ LAST_DAY_RULE,
+ ]) {
+ expect(ruleFromPreset(presetFromRule(regel, "2026-01-01"))).toBe(regel);
+ }
+ });
+});
+
+describe("Deutsche Beschreibung", () => {
+ it("beschreibt monatliche Regeln", () => {
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-03-01")).toBe(
+ "Jeden 1. des Monats, ab 01.03.2026",
+ );
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=15")).toBe("Jeden 15. des Monats");
+ });
+
+ it("beschreibt Abstände größer als einen Monat", () => {
+ expect(describeRRule("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15")).toBe(
+ "Quartalsweise am 15.",
+ );
+ expect(describeRRule("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10")).toBe(
+ "Alle 2 Monate am 10.",
+ );
+ });
+
+ it("beschreibt den Monatsletzten", () => {
+ expect(describeRRule(LAST_DAY_RULE)).toBe("Jeden Monat am letzten Tag");
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=-1")).toBe("Jeden Monat am letzten Tag");
+ });
+
+ it("beschreibt jährliche und wöchentliche Regeln", () => {
+ expect(describeRRule("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15")).toBe("Jährlich am 15. Januar");
+ expect(describeRRule("FREQ=WEEKLY;BYDAY=MO,TH")).toBe("Jede Woche montags und donnerstags");
+ expect(describeRRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=FR")).toBe("Alle 2 Wochen freitags");
+ });
+
+ it("nennt Serienende und Anzahl", () => {
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-01-01", "2026-12-01")).toBe(
+ "Jeden 1. des Monats, ab 01.01.2026, bis 01.12.2026",
+ );
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36")).toBe(
+ "Jeden 1. des Monats, 36 Mal",
+ );
+ expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1;UNTIL=20261231")).toBe(
+ "Jeden 1. des Monats, bis 31.12.2026",
+ );
+ });
+
+ it("bleibt bei leerer Regel verständlich", () => {
+ expect(describeRRule("")).toBe("Keine Wiederholungsregel");
+ });
+});
diff --git a/frontend/src/lib/rrule.ts b/frontend/src/lib/rrule.ts
new file mode 100644
index 0000000..49b4940
--- /dev/null
+++ b/frontend/src/lib/rrule.ts
@@ -0,0 +1,294 @@
+/**
+ * Umgang mit RFC-5545-Wiederholungsregeln im Frontend.
+ *
+ * Die Berechnung der Termine bleibt Sache des Backends – hier geht es nur um
+ * das Zusammenbauen der Regel im geführten Editor und um eine verständliche
+ * deutsche Beschreibung.
+ */
+
+import { formatDate } from "@/lib/format";
+
+export type RRuleParts = Record;
+
+export const WEEKDAYS = [
+ { code: "MO", short: "Mo", label: "Montag", adverb: "montags" },
+ { code: "TU", short: "Di", label: "Dienstag", adverb: "dienstags" },
+ { code: "WE", short: "Mi", label: "Mittwoch", adverb: "mittwochs" },
+ { code: "TH", short: "Do", label: "Donnerstag", adverb: "donnerstags" },
+ { code: "FR", short: "Fr", label: "Freitag", adverb: "freitags" },
+ { code: "SA", short: "Sa", label: "Samstag", adverb: "samstags" },
+ { code: "SU", short: "So", label: "Sonntag", adverb: "sonntags" },
+] as const;
+
+export const MONTHS = [
+ "Januar",
+ "Februar",
+ "März",
+ "April",
+ "Mai",
+ "Juni",
+ "Juli",
+ "August",
+ "September",
+ "Oktober",
+ "November",
+ "Dezember",
+] as const;
+
+/** Muster für den Monatsletzten über BYSETPOS – so schreibt es der Editor. */
+export const LAST_DAY_RULE = "FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1";
+
+export function parseRRule(rrule: string): RRuleParts {
+ const teile: RRuleParts = {};
+ for (const abschnitt of rrule.split(";")) {
+ const [schluessel, wert] = abschnitt.split("=");
+ if (schluessel && wert) teile[schluessel.trim().toUpperCase()] = wert.trim();
+ }
+ return teile;
+}
+
+export function buildRRule(parts: RRuleParts): string {
+ // FREQ steht laut RFC an erster Stelle, der Rest folgt in fester Reihenfolge.
+ const reihenfolge = [
+ "FREQ",
+ "INTERVAL",
+ "BYMONTH",
+ "BYMONTHDAY",
+ "BYDAY",
+ "BYSETPOS",
+ "COUNT",
+ "UNTIL",
+ ];
+ return reihenfolge
+ .filter((schluessel) => parts[schluessel])
+ .map((schluessel) => `${schluessel}=${parts[schluessel]}`)
+ .join(";");
+}
+
+/* --- Vorlagen des geführten Editors --------------------------------------- */
+
+export type PresetId =
+ | "monthly"
+ | "everyNMonths"
+ | "quarterly"
+ | "yearly"
+ | "weekly"
+ | "lastDayOfMonth"
+ | "custom";
+
+export interface PresetOption {
+ id: PresetId;
+ label: string;
+ hint: string;
+}
+
+export const PRESETS: PresetOption[] = [
+ { id: "monthly", label: "Monatlich", hint: "An einem festen Tag jedes Monats" },
+ { id: "everyNMonths", label: "Alle N Monate", hint: "Zweimonatlich, halbjährlich, …" },
+ { id: "quarterly", label: "Quartalsweise", hint: "Alle drei Monate" },
+ { id: "yearly", label: "Jährlich", hint: "Einmal im Jahr zu festem Datum" },
+ { id: "weekly", label: "Wöchentlich", hint: "An festen Wochentagen" },
+ { id: "lastDayOfMonth", label: "Monatsletzter", hint: "Am letzten Tag des Monats" },
+ { id: "custom", label: "Expertenmodus", hint: "RRULE von Hand eingeben" },
+];
+
+export interface PresetState {
+ preset: PresetId;
+ monthDay: number;
+ interval: number;
+ month: number;
+ weekdays: string[];
+ custom: string;
+}
+
+export const DEFAULT_PRESET_STATE: PresetState = {
+ preset: "monthly",
+ monthDay: 1,
+ interval: 2,
+ month: 1,
+ weekdays: ["MO"],
+ custom: "FREQ=MONTHLY;BYMONTHDAY=1",
+};
+
+export function ruleFromPreset(state: PresetState): string {
+ switch (state.preset) {
+ case "monthly":
+ return buildRRule({ FREQ: "MONTHLY", BYMONTHDAY: String(state.monthDay) });
+ case "everyNMonths":
+ return buildRRule({
+ FREQ: "MONTHLY",
+ INTERVAL: String(Math.max(1, state.interval)),
+ BYMONTHDAY: String(state.monthDay),
+ });
+ case "quarterly":
+ return buildRRule({ FREQ: "MONTHLY", INTERVAL: "3", BYMONTHDAY: String(state.monthDay) });
+ case "yearly":
+ return buildRRule({
+ FREQ: "YEARLY",
+ BYMONTH: String(state.month),
+ BYMONTHDAY: String(state.monthDay),
+ });
+ case "weekly":
+ return buildRRule({
+ FREQ: "WEEKLY",
+ ...(state.interval > 1 ? { INTERVAL: String(state.interval) } : {}),
+ BYDAY: (state.weekdays.length ? state.weekdays : ["MO"]).join(","),
+ });
+ case "lastDayOfMonth":
+ return LAST_DAY_RULE;
+ case "custom":
+ return state.custom.trim();
+ }
+}
+
+/** Erkennt die passende Vorlage zu einer bestehenden Regel – für das Bearbeiten. */
+export function presetFromRule(rrule: string, dtstart: string): PresetState {
+ const teile = parseRRule(rrule);
+ const startTag = Number(dtstart.slice(8, 10)) || 1;
+ const basis: PresetState = { ...DEFAULT_PRESET_STATE, monthDay: startTag, custom: rrule };
+
+ const normalisiert = buildRRule(teile);
+ if (normalisiert === LAST_DAY_RULE || teile.BYMONTHDAY === "-1") {
+ return { ...basis, preset: "lastDayOfMonth" };
+ }
+
+ const intervall = Number(teile.INTERVAL ?? "1") || 1;
+ const monatstag = Number(teile.BYMONTHDAY ?? startTag) || startTag;
+ // Alles, was über die Vorlagen hinausgeht, gehört in den Expertenmodus.
+ const zusatz = Object.keys(teile).filter(
+ (schluessel) =>
+ !["FREQ", "INTERVAL", "BYMONTHDAY", "BYDAY", "BYMONTH"].includes(schluessel),
+ );
+ if (zusatz.length > 0) return { ...basis, preset: "custom" };
+
+ if (teile.FREQ === "MONTHLY" && !teile.BYDAY) {
+ if (intervall === 1) return { ...basis, preset: "monthly", monthDay: monatstag };
+ if (intervall === 3) return { ...basis, preset: "quarterly", monthDay: monatstag };
+ return { ...basis, preset: "everyNMonths", interval: intervall, monthDay: monatstag };
+ }
+
+ if (teile.FREQ === "YEARLY" && teile.BYMONTH && !teile.BYDAY) {
+ return {
+ ...basis,
+ preset: "yearly",
+ month: Number(teile.BYMONTH) || 1,
+ monthDay: monatstag,
+ };
+ }
+
+ if (teile.FREQ === "WEEKLY") {
+ return {
+ ...basis,
+ preset: "weekly",
+ interval: intervall,
+ weekdays: (teile.BYDAY ?? "MO").split(","),
+ };
+ }
+
+ return { ...basis, preset: "custom" };
+}
+
+/* --- Klartext ------------------------------------------------------------- */
+
+function joinGerman(items: string[]): string {
+ if (items.length === 0) return "";
+ if (items.length === 1) return items[0]!;
+ return `${items.slice(0, -1).join(", ")} und ${items.at(-1)}`;
+}
+
+function parseUntil(value: string): string | null {
+ // UNTIL kommt als 20261231 oder 20261231T000000Z.
+ const treffer = /^(\d{4})(\d{2})(\d{2})/.exec(value);
+ if (!treffer) return null;
+ return `${treffer[1]}-${treffer[2]}-${treffer[3]}`;
+}
+
+/**
+ * Deutsche Beschreibung einer Regel, etwa
+ * „Jeden 1. des Monats, ab 01.03.2026“.
+ */
+export function describeRRule(rrule: string, dtstart?: string, until?: string | null): string {
+ const regel = rrule.trim();
+ if (!regel) return "Keine Wiederholungsregel";
+
+ const teile = parseRRule(regel);
+ const intervall = Number(teile.INTERVAL ?? "1") || 1;
+ const saetze: string[] = [];
+
+ const monatstage = (teile.BYMONTHDAY ?? "").split(",").filter(Boolean);
+ const istMonatsletzter =
+ teile.BYSETPOS === "-1" || monatstage.includes("-1");
+
+ switch (teile.FREQ) {
+ case "DAILY":
+ saetze.push(intervall === 1 ? "Jeden Tag" : `Alle ${intervall} Tage`);
+ break;
+
+ case "WEEKLY": {
+ const tage = (teile.BYDAY ?? "")
+ .split(",")
+ .filter(Boolean)
+ .map((code) => WEEKDAYS.find((tag) => tag.code === code)?.adverb ?? code);
+ const rhythmus = intervall === 1 ? "Jede Woche" : `Alle ${intervall} Wochen`;
+ saetze.push(tage.length ? `${rhythmus} ${joinGerman(tage)}` : rhythmus);
+ break;
+ }
+
+ case "MONTHLY": {
+ const rhythmus =
+ intervall === 1
+ ? "Jeden Monat"
+ : intervall === 3
+ ? "Quartalsweise"
+ : `Alle ${intervall} Monate`;
+
+ if (istMonatsletzter) {
+ saetze.push(`${rhythmus} am letzten Tag`);
+ } else if (teile.BYDAY) {
+ const tage = teile.BYDAY.split(",")
+ .map((code) => WEEKDAYS.find((tag) => tag.code === code.slice(-2))?.adverb ?? code)
+ .filter(Boolean);
+ saetze.push(`${rhythmus} ${joinGerman(tage)}`);
+ } else if (monatstage.length) {
+ const tage = monatstage.map((tag) => `${tag}.`);
+ saetze.push(
+ intervall === 1
+ ? `Jeden ${joinGerman(tage)} des Monats`
+ : `${rhythmus} am ${joinGerman(tage)}`,
+ );
+ } else {
+ saetze.push(rhythmus);
+ }
+ break;
+ }
+
+ case "YEARLY": {
+ const monate = (teile.BYMONTH ?? "")
+ .split(",")
+ .filter(Boolean)
+ .map((nummer) => MONTHS[Number(nummer) - 1] ?? nummer);
+ const tag = monatstage[0];
+ const rhythmus = intervall === 1 ? "Jährlich" : `Alle ${intervall} Jahre`;
+
+ if (monate.length && tag) {
+ saetze.push(`${rhythmus} am ${tag}. ${joinGerman(monate)}`);
+ } else if (monate.length) {
+ saetze.push(`${rhythmus} im ${joinGerman(monate)}`);
+ } else {
+ saetze.push(rhythmus);
+ }
+ break;
+ }
+
+ default:
+ return regel;
+ }
+
+ if (dtstart) saetze.push(`ab ${formatDate(dtstart)}`);
+
+ const ende = until ?? (teile.UNTIL ? parseUntil(teile.UNTIL) : null);
+ if (ende) saetze.push(`bis ${formatDate(ende)}`);
+ if (teile.COUNT) saetze.push(`${teile.COUNT} Mal`);
+
+ return saetze.join(", ");
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..d28732d
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,24 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+
+import { QueryClientProvider } from "@tanstack/react-query";
+
+import { App } from "@/App";
+import { Toaster } from "@/components/ui/Toaster";
+import "@/index.css";
+import { queryClient } from "@/lib/queryClient";
+
+const wurzel = document.getElementById("root");
+if (!wurzel) throw new Error("Das Element #root fehlt im Dokument.");
+
+createRoot(wurzel).render(
+
+
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx
new file mode 100644
index 0000000..5ace54a
--- /dev/null
+++ b/frontend/src/pages/ChangePasswordPage.tsx
@@ -0,0 +1,98 @@
+/** Erzwungener Passwortwechsel nach dem ersten Anmelden. */
+
+import { type FormEvent, useState } from "react";
+
+import { Button } from "@/components/ui/Button";
+import { Field, Input } from "@/components/ui/Field";
+import { useChangePassword } from "@/hooks/useAuth";
+
+const MINDESTLAENGE = 10;
+
+export function ChangePasswordPage() {
+ const [aktuell, setAktuell] = useState("");
+ const [neu, setNeu] = useState("");
+ const [wiederholung, setWiederholung] = useState("");
+ const wechseln = useChangePassword();
+
+ const stimmtUeberein = neu === wiederholung;
+ const langGenug = neu.length >= MINDESTLAENGE;
+ const absendbar = Boolean(aktuell) && langGenug && stimmtUeberein;
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ if (!absendbar) return;
+ wechseln.mutate({ current_password: aktuell, new_password: neu });
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..eb1f77e
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,83 @@
+import { type FormEvent, useState } from "react";
+
+import { Field, Input } from "@/components/ui/Field";
+import { Button } from "@/components/ui/Button";
+import { loginErrorMessage, useLogin } from "@/hooks/useAuth";
+
+export function LoginPage() {
+ const [benutzername, setBenutzername] = useState("");
+ const [passwort, setPasswort] = useState("");
+ const anmelden = useLogin();
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ anmelden.mutate({ username: benutzername, password: passwort });
+ }
+
+ return (
+
+
+
+
+ m
+
+ moneyfy
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/MerchantsPage.tsx b/frontend/src/pages/MerchantsPage.tsx
new file mode 100644
index 0000000..b157b15
--- /dev/null
+++ b/frontend/src/pages/MerchantsPage.tsx
@@ -0,0 +1,306 @@
+/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
+
+import { type FormEvent, useMemo, useState } from "react";
+
+import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
+
+import { MerchantLogo } from "@/components/MerchantLogo";
+import { MerchantLogoDialog } from "@/components/MerchantLogoDialog";
+import { PageHeader } from "@/components/layout/AppLayout";
+import { Button } from "@/components/ui/Button";
+import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
+import { Field, Input } from "@/components/ui/Field";
+import { Modal } from "@/components/ui/Modal";
+import {
+ useDeleteMerchant,
+ useMerchants,
+ useRecurrences,
+ useSaveMerchant,
+} from "@/hooks/useEntities";
+import { formatMoney, toNumber } from "@/lib/format";
+import { annualCostOf } from "@/lib/recurrenceMath";
+import { useThemeStore } from "@/store/theme";
+import type { Merchant } from "@/types/api";
+
+export function MerchantsPage() {
+ const [suche, setSuche] = useState("");
+ const [bearbeiten, setBearbeiten] = useState(undefined);
+ const [logoFuer, setLogoFuer] = useState(null);
+ const [loeschen, setLoeschen] = useState(null);
+
+ const { data, isLoading } = useMerchants(suche || undefined);
+ const { data: posten = [] } = useRecurrences({ active: true });
+ const entfernen = useDeleteMerchant();
+
+ // Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
+ const kennzahlen = useMemo(() => {
+ const werte = new Map();
+ for (const eintrag of posten) {
+ if (eintrag.merchant_id === null) continue;
+ const bisher = werte.get(eintrag.merchant_id) ?? { jahr: 0, anzahl: 0 };
+ const betrag = annualCostOf(eintrag.rrule, toNumber(eintrag.amount));
+ werte.set(eintrag.merchant_id, {
+ jahr: bisher.jahr + (eintrag.kind === "expense" ? betrag : 0),
+ anzahl: bisher.anzahl + 1,
+ });
+ }
+ return werte;
+ }, [posten]);
+
+ const firmen = data?.items ?? [];
+
+ return (
+ <>
+
+
+
+ setSuche(ereignis.target.value)}
+ placeholder="Firma suchen"
+ aria-label="Firma suchen"
+ className="h-10 w-52 pl-8"
+ />
+
+
+ >
+ }
+ />
+
+ {isLoading ? (
+
+ {Array.from({ length: 8 }, (_, index) => (
+
+ ))}
+
+ ) : firmen.length === 0 ? (
+ setBearbeiten(null)}>
+
+ Erste Firma anlegen
+
+ )
+ }
+ />
+ ) : (
+
+ {firmen.map((firma) => (
+ setBearbeiten(firma)}
+ onLogo={() => setLogoFuer(firma)}
+ onDelete={() => setLoeschen(firma)}
+ />
+ ))}
+
+ )}
+
+ setBearbeiten(undefined)}
+ />
+
+ setLogoFuer(null)}
+ />
+
+ setLoeschen(null)}
+ onConfirm={() => {
+ if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
+ }}
+ />
+ >
+ );
+}
+
+function MerchantCard({
+ merchant,
+ metrics,
+ onEdit,
+ onLogo,
+ onDelete,
+}: {
+ merchant: Merchant;
+ metrics?: { jahr: number; anzahl: number };
+ onEdit: () => void;
+ onLogo: () => void;
+ onDelete: () => void;
+}) {
+ const theme = useThemeStore((zustand) => zustand.theme);
+ const akzent =
+ (theme === "dark" ? merchant.brand_color_dark : merchant.brand_color) ?? undefined;
+
+ return (
+
+
+
+
+
+
+
+ {merchant.name}
+
+ {merchant.domain && (
+
+ {merchant.domain}
+
+ )}
+
+
+
+
+ {metrics && metrics.anzahl > 0 ? (
+ <>
+
+ {formatMoney(metrics.jahr)} p. a.
+
+
+ {metrics.anzahl} {metrics.anzahl === 1 ? "Vertrag" : "Verträge"}
+
+ >
+ ) : (
+ Keine aktiven Verträge
+ )}
+ {merchant.logo_status === "manual" && Logo manuell}
+
+
+
+
+
+
+
+
+ );
+}
+
+function MerchantFormDialog({
+ merchant,
+ open,
+ onClose,
+}: {
+ merchant: Merchant | null | undefined;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const speichern = useSaveMerchant();
+ const [name, setName] = useState("");
+ const [domain, setDomain] = useState("");
+ const [initialisiert, setInitialisiert] = useState(undefined);
+
+ // Formular auf die geöffnete Firma setzen.
+ if (open && initialisiert !== (merchant?.id ?? null)) {
+ setName(merchant?.name ?? "");
+ setDomain(merchant?.domain ?? "");
+ setInitialisiert(merchant?.id ?? null);
+ }
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ speichern.mutate(
+ {
+ id: merchant?.id,
+ daten: { name: name.trim(), domain: domain.trim() || null },
+ },
+ {
+ onSuccess: () => {
+ setInitialisiert(undefined);
+ onClose();
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/src/pages/RecurrencesPage.tsx b/frontend/src/pages/RecurrencesPage.tsx
new file mode 100644
index 0000000..2ad3428
--- /dev/null
+++ b/frontend/src/pages/RecurrencesPage.tsx
@@ -0,0 +1,201 @@
+/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
+
+import { useState } from "react";
+
+import { Plus, Repeat, Search } from "lucide-react";
+
+import { useCategoryLookup } from "@/hooks/useCategoryLookup";
+import { MerchantLogo } from "@/components/MerchantLogo";
+import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
+import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
+import { PageHeader } from "@/components/layout/AppLayout";
+import { Button } from "@/components/ui/Button";
+import { Badge, EmptyState, TableSkeleton } from "@/components/ui/Feedback";
+import { Input, Select } from "@/components/ui/Field";
+import { type RecurrenceFilter, useMerchants, useRecurrences } from "@/hooks/useEntities";
+import { formatMoney, toNumber } from "@/lib/format";
+import { monthlyCostOf } from "@/lib/recurrenceMath";
+import { describeRRule } from "@/lib/rrule";
+import type { Recurrence } from "@/types/api";
+
+export function RecurrencesPage() {
+ const [filter, setFilter] = useState({ active: true });
+ const [suche, setSuche] = useState("");
+ const [formularFuer, setFormularFuer] = useState(undefined);
+ const [detailId, setDetailId] = useState(null);
+
+ const { data: posten = [], isLoading } = useRecurrences(filter);
+ const { data: firmenSeite } = useMerchants();
+ const kategorieName = useCategoryLookup();
+
+ const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
+ const begriff = suche.trim().toLowerCase();
+ const sichtbar = begriff
+ ? posten.filter((eintrag) => eintrag.title.toLowerCase().includes(begriff))
+ : posten;
+
+ const monatssumme = sichtbar.reduce((summe, eintrag) => {
+ const betrag = monthlyCostOf(eintrag.rrule, toNumber(eintrag.amount));
+ return summe + (eintrag.kind === "expense" ? -betrag : betrag);
+ }, 0);
+
+ return (
+ <>
+
+
+
+ setSuche(ereignis.target.value)}
+ placeholder="Posten suchen"
+ aria-label="Posten suchen"
+ className="h-10 w-48 pl-8"
+ />
+
+
+
+
+ >
+ }
+ />
+
+ {isLoading ? (
+
+ ) : sichtbar.length === 0 ? (
+ setFormularFuer(null)}>
+
+ Ersten Posten anlegen
+
+ )
+ }
+ />
+ ) : (
+ <>
+
+ {sichtbar.map((eintrag) => {
+ const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+ Geschätzter Monatssaldo dieser Auswahl:{" "}
+
+ {formatMoney(monatssumme)}
+
+
+ >
+ )}
+
+ setFormularFuer(undefined)}
+ />
+
+ setDetailId(null)}
+ onEdit={(posten) => {
+ setDetailId(null);
+ setFormularFuer(posten);
+ }}
+ />
+ >
+ );
+}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..2fd6194
--- /dev/null
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -0,0 +1,687 @@
+/** Einstellungen: Konten, Kategorien und das eigene Konto. */
+
+import { type FormEvent, useState } from "react";
+
+import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
+
+import { PageHeader } from "@/components/layout/AppLayout";
+import { Button } from "@/components/ui/Button";
+import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
+import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
+import { Modal } from "@/components/ui/Modal";
+import { MoneyInput } from "@/components/ui/MoneyInput";
+import { useChangePassword, useMe } from "@/hooks/useAuth";
+import {
+ useAccountBalance,
+ useAccounts,
+ useCategoryTree,
+ useDeleteAccount,
+ useDeleteCategory,
+ useSaveAccount,
+ useSaveCategory,
+} from "@/hooks/useEntities";
+import { formatDate, formatMoney, todayIso } from "@/lib/format";
+import { useThemeStore } from "@/store/theme";
+import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
+
+const KONTOARTEN: Record = {
+ checking: "Girokonto",
+ credit_card: "Kreditkarte",
+ savings: "Sparkonto",
+ cash: "Bargeld",
+};
+
+type Reiter = "accounts" | "categories" | "account";
+
+const REITER: { id: Reiter; label: string }[] = [
+ { id: "accounts", label: "Konten" },
+ { id: "categories", label: "Kategorien" },
+ { id: "account", label: "Konto & Darstellung" },
+];
+
+export function SettingsPage() {
+ const [reiter, setReiter] = useState("accounts");
+
+ return (
+ <>
+
+
+
+ {REITER.map((eintrag) => (
+
+ ))}
+
+
+ {reiter === "accounts" && }
+ {reiter === "categories" && }
+ {reiter === "account" && }
+ >
+ );
+}
+
+/* --- Konten --------------------------------------------------------------- */
+
+function AccountsSection() {
+ const { data: konten = [], isLoading } = useAccounts();
+ const [bearbeiten, setBearbeiten] = useState(undefined);
+ const [loeschen, setLoeschen] = useState(null);
+ const entfernen = useDeleteAccount();
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+
+ ) : konten.length === 0 ? (
+ setBearbeiten(null)}>
+
+ Erstes Konto anlegen
+
+ }
+ />
+ ) : (
+
+ {konten.map((konto) => (
+ setBearbeiten(konto)}
+ onDelete={() => setLoeschen(konto)}
+ />
+ ))}
+
+ )}
+
+ setBearbeiten(undefined)}
+ />
+
+ setLoeschen(null)}
+ onConfirm={() => {
+ if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
+ }}
+ />
+
+ );
+}
+
+function AccountRow({
+ account,
+ onEdit,
+ onDelete,
+}: {
+ account: Account;
+ onEdit: () => void;
+ onDelete: () => void;
+}) {
+ const { data: saldo } = useAccountBalance(account.id);
+
+ return (
+
+
+
+
+ {account.name}
+ {KONTOARTEN[account.type]}
+ {!account.is_active && Inaktiv}
+
+
+ {account.iban_last4 ? `IBAN …${account.iban_last4} · ` : ""}
+ Eröffnet {formatDate(account.opening_balance_date)}
+
+
+
+
+
Saldo heute
+
+ {saldo ? formatMoney(saldo.balance) : "…"}
+
+
+
+
+
+ );
+}
+
+function AccountDialog({
+ account,
+ open,
+ onClose,
+}: {
+ account: Account | null | undefined;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const speichern = useSaveAccount();
+ const [name, setName] = useState("");
+ const [art, setArt] = useState("checking");
+ const [iban, setIban] = useState("");
+ const [saldo, setSaldo] = useState("0.00");
+ const [stichtag, setStichtag] = useState(todayIso());
+ const [farbe, setFarbe] = useState("#3b82f6");
+ const [aktiv, setAktiv] = useState(true);
+ const [initialisiert, setInitialisiert] = useState(undefined);
+
+ if (open && initialisiert !== (account?.id ?? null)) {
+ setName(account?.name ?? "");
+ setArt(account?.type ?? "checking");
+ setIban(account?.iban_last4 ?? "");
+ setSaldo(account?.opening_balance ?? "0.00");
+ setStichtag(account?.opening_balance_date ?? todayIso());
+ setFarbe(account?.color ?? "#3b82f6");
+ setAktiv(account?.is_active ?? true);
+ setInitialisiert(account?.id ?? null);
+ }
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ speichern.mutate(
+ {
+ id: account?.id,
+ daten: {
+ name: name.trim(),
+ type: art,
+ iban_last4: iban.trim() || null,
+ opening_balance: saldo || "0.00",
+ opening_balance_date: stichtag,
+ color: farbe,
+ is_active: aktiv,
+ },
+ },
+ {
+ onSuccess: () => {
+ setInitialisiert(undefined);
+ onClose();
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* --- Kategorien ----------------------------------------------------------- */
+
+function CategoriesSection() {
+ const { data: baum = [], isLoading } = useCategoryTree();
+ const [bearbeiten, setBearbeiten] = useState<
+ { kategorie: Category | null; parent: CategoryTree | null; kind: EntryKind } | undefined
+ >(undefined);
+ const [loeschen, setLoeschen] = useState(null);
+ const entfernen = useDeleteCategory();
+
+ if (isLoading) return ;
+
+ return (
+
+ {(["expense", "income"] as EntryKind[]).map((richtung) => {
+ const gruppen = baum.filter((oberkategorie) => oberkategorie.kind === richtung);
+ return (
+
+
+
+ {richtung === "expense" ? "Ausgaben" : "Einkünfte"}
+
+
+
+
+
+ {gruppen.map((oberkategorie) => (
+ -
+
+
+
+ {oberkategorie.name}
+
+ {oberkategorie.is_fixed_cost &&
Fixkosten}
+
+
+
+
+
+
+
+ {oberkategorie.children.length > 0 && (
+
+ {oberkategorie.children.map((unterkategorie) => (
+ -
+ {unterkategorie.name}
+ {unterkategorie.is_fixed_cost && Fix}
+
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ );
+ })}
+
+ {bearbeiten && (
+ setBearbeiten(undefined)}
+ />
+ )}
+
+ setLoeschen(null)}
+ onConfirm={() => {
+ if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
+ }}
+ />
+
+ );
+}
+
+function CategoryDialog({
+ category,
+ parent,
+ kind,
+ open,
+ onClose,
+}: {
+ category: Category | null;
+ parent: CategoryTree | null;
+ kind: EntryKind;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const speichern = useSaveCategory();
+ const [name, setName] = useState(category?.name ?? "");
+ const [farbe, setFarbe] = useState(category?.color ?? parent?.color ?? "#64748b");
+ const [fixkosten, setFixkosten] = useState(category?.is_fixed_cost ?? parent?.is_fixed_cost ?? false);
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ speichern.mutate(
+ {
+ id: category?.id,
+ daten: category
+ ? { name: name.trim(), color: farbe, is_fixed_cost: fixkosten }
+ : {
+ name: name.trim(),
+ kind,
+ parent_id: parent?.id ?? null,
+ color: farbe,
+ is_fixed_cost: fixkosten,
+ },
+ },
+ { onSuccess: onClose },
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+/* --- Konto und Darstellung ------------------------------------------------ */
+
+function UserSection() {
+ const { data: benutzer } = useMe();
+ const theme = useThemeStore((zustand) => zustand.theme);
+ const setTheme = useThemeStore((zustand) => zustand.setTheme);
+ const wechseln = useChangePassword();
+
+ const [aktuell, setAktuell] = useState("");
+ const [neu, setNeu] = useState("");
+ const [wiederholung, setWiederholung] = useState("");
+
+ const absendbar = Boolean(aktuell) && neu.length >= 10 && neu === wiederholung;
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ if (!absendbar) return;
+ wechseln.mutate({ current_password: aktuell, new_password: neu });
+ }
+
+ return (
+
+
+ Darstellung
+
+ Die Wahl wird im Browser gespeichert und gilt für dieses Gerät.
+
+
+ {(["dark", "light"] as const).map((wert) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/TransactionsPage.test.tsx b/frontend/src/pages/TransactionsPage.test.tsx
new file mode 100644
index 0000000..2f19cb8
--- /dev/null
+++ b/frontend/src/pages/TransactionsPage.test.tsx
@@ -0,0 +1,249 @@
+/** Deckt den CRUD-Fluss einer Buchung über die Oberfläche ab. */
+
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { TransactionsPage } from "@/pages/TransactionsPage";
+import { renderWithProviders } from "@/test/utils";
+import type { Account, CategoryTree, Transaction } from "@/types/api";
+
+const KONTO: Account = {
+ id: 1,
+ name: "Girokonto",
+ type: "checking",
+ iban_last4: "4711",
+ opening_balance: "1000.00",
+ opening_balance_date: "2026-01-01",
+ color: "#3b82f6",
+ icon: "wallet",
+ is_active: true,
+ sort_order: 0,
+ created_at: "2026-01-01T10:00:00+01:00",
+ updated_at: "2026-01-01T10:00:00+01:00",
+};
+
+const KATEGORIEN: CategoryTree[] = [
+ {
+ id: 10,
+ parent_id: null,
+ name: "Lebenshaltung",
+ kind: "expense",
+ color: "#84cc16",
+ icon: "shopping-basket",
+ is_fixed_cost: false,
+ sort_order: 0,
+ is_archived: false,
+ children: [
+ {
+ id: 11,
+ parent_id: 10,
+ name: "Lebensmittel",
+ kind: "expense",
+ color: "#84cc16",
+ icon: "shopping-cart",
+ is_fixed_cost: false,
+ sort_order: 0,
+ is_archived: false,
+ },
+ ],
+ },
+];
+
+const BUCHUNG: Transaction = {
+ id: 5,
+ kind: "expense",
+ title: "Wocheneinkauf",
+ merchant_id: null,
+ category_id: 11,
+ account_id: 1,
+ amount: "84.30",
+ booking_date: "2026-03-05",
+ note: null,
+ tags: [],
+ created_at: "2026-03-05T18:00:00+01:00",
+ merchant: null,
+};
+
+/** Bildet die benötigten Endpunkte nach und protokolliert schreibende Zugriffe. */
+function mockApi(buchungen: Transaction[]) {
+ const bestand = [...buchungen];
+ const anfragen: { url: string; method: string; body: unknown }[] = [];
+
+ const json = (daten: unknown, status = 200) =>
+ new Response(JSON.stringify(daten), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
+ const methode = init?.method ?? "GET";
+ const rumpf = typeof init?.body === "string" ? JSON.parse(init.body) : null;
+ anfragen.push({ url, method: methode, body: rumpf });
+
+ if (url.includes("/api/accounts")) return json([KONTO]);
+ if (url.includes("/api/categories")) return json(KATEGORIEN);
+ if (url.includes("/api/merchants")) {
+ return json({ items: [], total: 0, limit: 200, offset: 0 });
+ }
+
+ if (url.includes("/api/transactions")) {
+ if (methode === "POST") {
+ const neu: Transaction = { ...BUCHUNG, id: 99, ...rumpf, merchant: null };
+ bestand.unshift(neu);
+ return json(neu, 201);
+ }
+ if (methode === "PATCH") {
+ const index = bestand.findIndex((eintrag) => url.endsWith(String(eintrag.id)));
+ if (index >= 0) bestand[index] = { ...bestand[index]!, ...rumpf };
+ return json(bestand[index]);
+ }
+ if (methode === "DELETE") {
+ const index = bestand.findIndex((eintrag) => url.endsWith(String(eintrag.id)));
+ if (index >= 0) bestand.splice(index, 1);
+ return json({ detail: "Buchung gelöscht." });
+ }
+ return json({
+ items: bestand,
+ total: bestand.length,
+ limit: 25,
+ offset: 0,
+ });
+ }
+ return json({ detail: "Not Found", code: "not_found" }, 404);
+ }),
+ );
+
+ return anfragen;
+}
+
+describe("Buchungen über die Oberfläche", () => {
+ beforeEach(() => {
+ mockApi([BUCHUNG]);
+ });
+
+ it("zeigt vorhandene Buchungen in deutschem Format", async () => {
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
+ expect(screen.getByText("05.03.2026")).toBeInTheDocument();
+ expect(screen.getByText(/84,30/)).toBeInTheDocument();
+ expect(screen.getByText("Lebenshaltung · Lebensmittel")).toBeInTheDocument();
+ });
+
+ it("zeigt einen Leerzustand mit Aufforderung", async () => {
+ mockApi([]);
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("button", { name: /Erste Buchung erfassen/ })).toBeInTheDocument();
+ });
+
+ it("legt über das Formular eine Buchung an", async () => {
+ const anfragen = mockApi([]);
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
+ await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
+
+ const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
+ await nutzer.type(within(dialog).getByLabelText(/Titel/), "Drogerie");
+ await nutzer.type(within(dialog).getByLabelText(/Betrag/), "24,90");
+ await nutzer.selectOptions(within(dialog).getByLabelText(/Kategorie/), "11");
+
+ await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
+
+ await waitFor(() => {
+ const angelegt = anfragen.find(
+ (eintrag) => eintrag.method === "POST" && eintrag.url.includes("/api/transactions"),
+ );
+ expect(angelegt).toBeDefined();
+ // Der Betrag geht im API-Format über die Leitung.
+ expect(angelegt?.body).toMatchObject({
+ title: "Drogerie",
+ amount: "24.90",
+ category_id: 11,
+ account_id: 1,
+ kind: "expense",
+ });
+ });
+ });
+
+ it("verlangt Titel, Betrag und Kategorie", async () => {
+ mockApi([]);
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
+ await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
+
+ const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
+ expect(within(dialog).getByRole("button", { name: "Speichern" })).toBeDisabled();
+
+ await nutzer.type(within(dialog).getByLabelText(/Titel/), "Nur ein Titel");
+ expect(within(dialog).getByRole("button", { name: "Speichern" })).toBeDisabled();
+ });
+
+ it("bearbeitet eine vorhandene Buchung", async () => {
+ const anfragen = mockApi([BUCHUNG]);
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
+ await nutzer.click(screen.getByRole("button", { name: "Wocheneinkauf bearbeiten" }));
+
+ const dialog = await screen.findByRole("dialog", { name: "Buchung bearbeiten" });
+ const titel = within(dialog).getByLabelText(/Titel/);
+ await nutzer.clear(titel);
+ await nutzer.type(titel, "Großeinkauf");
+ await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
+
+ await waitFor(() => {
+ const geaendert = anfragen.find((eintrag) => eintrag.method === "PATCH");
+ expect(geaendert?.body).toMatchObject({ title: "Großeinkauf" });
+ });
+ });
+
+ it("löscht erst nach Rückfrage", async () => {
+ const anfragen = mockApi([BUCHUNG]);
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
+ await nutzer.click(screen.getByRole("button", { name: "Wocheneinkauf löschen" }));
+
+ const rueckfrage = await screen.findByRole("dialog", { name: "Buchung löschen" });
+ expect(within(rueckfrage).getByText(/endgültig gelöscht/)).toBeInTheDocument();
+ // Vor der Bestätigung darf nichts passiert sein.
+ expect(anfragen.some((eintrag) => eintrag.method === "DELETE")).toBe(false);
+
+ await nutzer.click(within(rueckfrage).getByRole("button", { name: "Löschen" }));
+
+ await waitFor(() => {
+ expect(anfragen.some((eintrag) => eintrag.method === "DELETE")).toBe(true);
+ });
+ });
+
+ it("leert die Kategorie beim Wechsel der Richtung", async () => {
+ mockApi([]);
+ const nutzer = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
+ await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
+
+ const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
+ await nutzer.selectOptions(within(dialog).getByLabelText(/Kategorie/), "11");
+ expect(within(dialog).getByLabelText(/Kategorie/)).toHaveValue("11");
+
+ // Eine Ausgabenkategorie passt nicht zu einer Einkunft.
+ await nutzer.selectOptions(within(dialog).getByLabelText(/Richtung/), "income");
+ expect(within(dialog).getByLabelText(/Kategorie/)).toHaveValue("");
+ });
+});
diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx
new file mode 100644
index 0000000..7927018
--- /dev/null
+++ b/frontend/src/pages/TransactionsPage.tsx
@@ -0,0 +1,457 @@
+/** Einmalige Buchungen: Liste, Filter und Formular. */
+
+import { type FormEvent, useState } from "react";
+
+import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
+
+import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
+import { useCategoryLookup } from "@/hooks/useCategoryLookup";
+import { MerchantLogo } from "@/components/MerchantLogo";
+import { PageHeader } from "@/components/layout/AppLayout";
+import { Button } from "@/components/ui/Button";
+import { ConfirmDialog, EmptyState, TableSkeleton } from "@/components/ui/Feedback";
+import { Field, Input, Select, Textarea } from "@/components/ui/Field";
+import { Modal } from "@/components/ui/Modal";
+import { MoneyInput } from "@/components/ui/MoneyInput";
+import {
+ type TransactionFilter,
+ useAccounts,
+ useDeleteTransaction,
+ useSaveTransaction,
+ useTransactions,
+} from "@/hooks/useEntities";
+import { formatDate, formatMoney, todayIso } from "@/lib/format";
+import type { EntryKind, Transaction } from "@/types/api";
+
+const SEITENGROESSE = 25;
+
+export function TransactionsPage() {
+ const [filter, setFilter] = useState({ limit: SEITENGROESSE, offset: 0 });
+ const [suche, setSuche] = useState("");
+ const [bearbeiten, setBearbeiten] = useState(undefined);
+ const [loeschen, setLoeschen] = useState(null);
+
+ const { data, isLoading } = useTransactions({ ...filter, q: suche || undefined });
+ const entfernen = useDeleteTransaction();
+ const kategorieName = useCategoryLookup();
+
+ const buchungen = data?.items ?? [];
+ const gesamt = data?.total ?? 0;
+ const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
+ const seiten = Math.max(1, Math.ceil(gesamt / SEITENGROESSE));
+
+ return (
+ <>
+
+
+
+ {
+ setSuche(ereignis.target.value);
+ setFilter((alt) => ({ ...alt, offset: 0 }));
+ }}
+ placeholder="Titel oder Notiz"
+ aria-label="Buchungen durchsuchen"
+ className="h-10 w-52 pl-8"
+ />
+
+
+
+ >
+ }
+ />
+
+ {isLoading ? (
+
+ ) : buchungen.length === 0 ? (
+ setBearbeiten(null)}>
+
+ Erste Buchung erfassen
+
+ )
+ }
+ />
+ ) : (
+ <>
+
+
+ Liste der Buchungen
+
+
+ |
+ Datum
+ |
+
+ Buchung
+ |
+
+ Kategorie
+ |
+
+ Betrag
+ |
+
+ Aktionen
+ |
+
+
+
+ {buchungen.map((buchung) => (
+
+ |
+ {formatDate(buchung.booking_date)}
+ |
+
+
+ {buchung.merchant ? (
+
+ ) : null}
+
+ {buchung.title}
+ {buchung.note && (
+ {buchung.note}
+ )}
+
+
+ |
+
+ {kategorieName(buchung.category_id)}
+ |
+
+ {buchung.kind === "income" ? "+" : "−"}
+ {formatMoney(buchung.amount)}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {seiten > 1 && (
+
+
+ {gesamt} {gesamt === 1 ? "Buchung" : "Buchungen"}
+
+
+
+
+ Seite {seite} von {seiten}
+
+
+
+
+ )}
+ >
+ )}
+
+ setBearbeiten(undefined)}
+ />
+
+ setLoeschen(null)}
+ onConfirm={() => {
+ if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
+ }}
+ />
+ >
+ );
+}
+
+interface FormularZustand {
+ kind: EntryKind;
+ title: string;
+ amount: string;
+ booking_date: string;
+ category_id: number | null;
+ account_id: number | null;
+ merchant_id: number | null;
+ note: string;
+}
+
+function leeresFormular(kontoId: number | null): FormularZustand {
+ return {
+ kind: "expense",
+ title: "",
+ amount: "",
+ booking_date: todayIso(),
+ category_id: null,
+ account_id: kontoId,
+ merchant_id: null,
+ note: "",
+ };
+}
+
+function TransactionDialog({
+ transaction,
+ open,
+ onClose,
+}: {
+ transaction: Transaction | null | undefined;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const { data: konten = [] } = useAccounts(true);
+ const speichern = useSaveTransaction();
+ const [formular, setFormular] = useState(() => leeresFormular(null));
+ const [initialisiert, setInitialisiert] = useState(undefined);
+
+ if (open && initialisiert !== (transaction?.id ?? null)) {
+ setFormular(
+ transaction
+ ? {
+ kind: transaction.kind,
+ title: transaction.title,
+ amount: transaction.amount,
+ booking_date: transaction.booking_date,
+ category_id: transaction.category_id,
+ account_id: transaction.account_id,
+ merchant_id: transaction.merchant_id,
+ note: transaction.note ?? "",
+ }
+ : leeresFormular(konten[0]?.id ?? null),
+ );
+ setInitialisiert(transaction?.id ?? null);
+ }
+
+ const absendbar =
+ formular.title.trim() !== "" &&
+ formular.amount !== "" &&
+ formular.category_id !== null &&
+ formular.account_id !== null;
+
+ function absenden(ereignis: FormEvent) {
+ ereignis.preventDefault();
+ if (!absendbar) return;
+ speichern.mutate(
+ {
+ id: transaction?.id,
+ daten: {
+ kind: formular.kind,
+ title: formular.title.trim(),
+ amount: formular.amount,
+ booking_date: formular.booking_date,
+ category_id: formular.category_id!,
+ account_id: formular.account_id!,
+ merchant_id: formular.merchant_id,
+ note: formular.note.trim() || null,
+ },
+ },
+ {
+ onSuccess: () => {
+ setInitialisiert(undefined);
+ onClose();
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/src/store/theme.ts b/frontend/src/store/theme.ts
new file mode 100644
index 0000000..5d48471
--- /dev/null
+++ b/frontend/src/store/theme.ts
@@ -0,0 +1,40 @@
+/** Umschalter zwischen dunkler und heller Oberfläche. Vorgabe ist dunkel. */
+
+import { create } from "zustand";
+
+export type Theme = "dark" | "light";
+
+const SPEICHER_SCHLUESSEL = "moneyfy.theme";
+
+function gespeichertesTheme(): Theme {
+ try {
+ return localStorage.getItem(SPEICHER_SCHLUESSEL) === "light" ? "light" : "dark";
+ } catch {
+ // Privater Modus ohne Speicher: es bleibt beim dunklen Standard.
+ return "dark";
+ }
+}
+
+function anwenden(theme: Theme): void {
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ try {
+ localStorage.setItem(SPEICHER_SCHLUESSEL, theme);
+ } catch {
+ /* Ohne Speicher gilt die Wahl nur für diese Sitzung. */
+ }
+}
+
+interface ThemeStore {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+}
+
+export const useThemeStore = create((set, get) => ({
+ theme: gespeichertesTheme(),
+ setTheme: (theme) => {
+ anwenden(theme);
+ set({ theme });
+ },
+ toggleTheme: () => get().setTheme(get().theme === "dark" ? "light" : "dark"),
+}));
diff --git a/frontend/src/store/toast.ts b/frontend/src/store/toast.ts
new file mode 100644
index 0000000..d6fc18b
--- /dev/null
+++ b/frontend/src/store/toast.ts
@@ -0,0 +1,51 @@
+/** Kurze Rückmeldungen am unteren Bildschirmrand. */
+
+import { create } from "zustand";
+
+export type ToastKind = "success" | "error" | "info";
+
+export interface Toast {
+ id: number;
+ kind: ToastKind;
+ title: string;
+ description?: string;
+}
+
+const ANZEIGEDAUER_MS: Record = {
+ success: 3500,
+ info: 4000,
+ error: 7000,
+};
+
+let naechsteId = 1;
+
+interface ToastStore {
+ toasts: Toast[];
+ push: (toast: Omit) => number;
+ dismiss: (id: number) => void;
+ clear: () => void;
+}
+
+export const useToastStore = create((set) => ({
+ toasts: [],
+ push: (toast) => {
+ const id = naechsteId++;
+ set((zustand) => ({ toasts: [...zustand.toasts, { ...toast, id }] }));
+ setTimeout(() => {
+ set((zustand) => ({ toasts: zustand.toasts.filter((eintrag) => eintrag.id !== id) }));
+ }, ANZEIGEDAUER_MS[toast.kind]);
+ return id;
+ },
+ dismiss: (id) =>
+ set((zustand) => ({ toasts: zustand.toasts.filter((eintrag) => eintrag.id !== id) })),
+ clear: () => set({ toasts: [] }),
+}));
+
+export const toast = {
+ success: (title: string, description?: string) =>
+ useToastStore.getState().push({ kind: "success", title, description }),
+ error: (title: string, description?: string) =>
+ useToastStore.getState().push({ kind: "error", title, description }),
+ info: (title: string, description?: string) =>
+ useToastStore.getState().push({ kind: "info", title, description }),
+};
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..966dc29
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,23 @@
+/** Gemeinsame Einrichtung aller Frontend-Tests. */
+
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab.
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+});
diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx
new file mode 100644
index 0000000..491c5e1
--- /dev/null
+++ b/frontend/src/test/utils.tsx
@@ -0,0 +1,58 @@
+/** Hilfsmittel zum Rendern von Komponenten in Tests. */
+
+import { type ReactElement, type ReactNode } from "react";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+
+/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
+export function createTestQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0, staleTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+}
+
+export function renderWithProviders(ui: ReactElement, route = "/") {
+ const client = createTestQueryClient();
+
+ function Rahmen({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ return { client, ...render(ui, { wrapper: Rahmen }) };
+}
+
+/** Antwortet auf `fetch` anhand einer Zuordnung von Pfadfragment zu Nutzlast. */
+export function mockFetch(routen: Record): ReturnType {
+ const nachbildung = vi.fn(async (eingabe: RequestInfo | URL) => {
+ const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
+ const treffer = Object.keys(routen).find((muster) => url.includes(muster));
+
+ if (treffer === undefined) {
+ return new Response(JSON.stringify({ detail: "Not Found", code: "not_found" }), {
+ status: 404,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify(routen[treffer]), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ });
+
+ vi.stubGlobal("fetch", nachbildung);
+ return nachbildung;
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..b695d4e
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,332 @@
+/**
+ * Typen der moneyfy-API.
+ *
+ * Sie spiegeln die Pydantic-Schemata des Backends. Geldbeträge kommen als
+ * String, damit unterwegs keine Genauigkeit verloren geht – zum Rechnen und
+ * Anzeigen dienen die Helfer aus `lib/format`.
+ */
+
+export type Money = string;
+export type IsoDate = string;
+export type IsoDateTime = string;
+
+export type EntryKind = "expense" | "income";
+export type AccountType = "checking" | "credit_card" | "savings" | "cash";
+export type BusinessDayShift = "none" | "next" | "previous";
+export type OccurrenceStatus = "planned" | "confirmed" | "skipped";
+export type LogoStatus = "pending" | "resolved" | "failed" | "manual";
+export type LogoSource =
+ | "simple-icons"
+ | "logodev"
+ | "brandfetch"
+ | "favicon"
+ | "upload"
+ | "generated";
+
+export interface ProblemDetail {
+ detail: string;
+ code: string;
+ errors?: { loc: string[]; msg: string; type: string }[];
+}
+
+export interface Page {
+ items: T[];
+ total: number;
+ limit: number;
+ offset: number;
+}
+
+export interface MessageResponse {
+ detail: string;
+}
+
+/* --- Benutzer ------------------------------------------------------------- */
+
+export interface User {
+ id: number;
+ username: string;
+ email: string | null;
+ must_change_password: boolean;
+ last_login_at: IsoDateTime | null;
+}
+
+/* --- Konten --------------------------------------------------------------- */
+
+export interface Account {
+ id: number;
+ name: string;
+ type: AccountType;
+ iban_last4: string | null;
+ opening_balance: Money;
+ opening_balance_date: IsoDate;
+ color: string;
+ icon: string;
+ is_active: boolean;
+ sort_order: number;
+ created_at: IsoDateTime;
+ updated_at: IsoDateTime;
+}
+
+export type AccountInput = Partial> & {
+ name: string;
+ opening_balance_date: IsoDate;
+};
+
+export interface AccountBalance {
+ account_id: number;
+ as_of: IsoDate;
+ opening_balance: Money;
+ booked_transactions: Money;
+ booked_occurrences: Money;
+ balance: Money;
+}
+
+/* --- Kategorien ----------------------------------------------------------- */
+
+export interface Category {
+ id: number;
+ parent_id: number | null;
+ name: string;
+ kind: EntryKind;
+ color: string;
+ icon: string;
+ is_fixed_cost: boolean;
+ sort_order: number;
+ is_archived: boolean;
+}
+
+export interface CategoryTree extends Category {
+ children: Category[];
+}
+
+export interface CategoryInput {
+ name: string;
+ kind: EntryKind;
+ parent_id?: number | null;
+ color?: string;
+ icon?: string;
+ is_fixed_cost?: boolean;
+ sort_order?: number;
+ is_archived?: boolean;
+}
+
+/* --- Firmen --------------------------------------------------------------- */
+
+export interface Merchant {
+ id: number;
+ name: string;
+ normalized_name: string;
+ domain: string | null;
+ aliases: string[];
+ logo_asset_id: number | null;
+ brand_color: string | null;
+ brand_color_dark: string | null;
+ logo_source: LogoSource | null;
+ logo_status: LogoStatus;
+ created_at: IsoDateTime;
+}
+
+export interface MerchantInput {
+ name: string;
+ domain?: string | null;
+ aliases?: string[];
+ brand_color?: string | null;
+ brand_color_dark?: string | null;
+}
+
+export interface LogoCandidate {
+ candidate_id: number;
+ source: LogoSource;
+ title: string;
+ score: number;
+ mime: string;
+ width: number | null;
+ height: number | null;
+ brand_color: string | null;
+ is_preselected: boolean;
+}
+
+export interface LogoSearchResult {
+ merchant_id: number;
+ candidates: LogoCandidate[];
+}
+
+/* --- Wiederkehrende Posten ------------------------------------------------ */
+
+export interface Recurrence {
+ id: number;
+ kind: EntryKind;
+ title: string;
+ merchant_id: number | null;
+ category_id: number;
+ account_id: number;
+ amount: Money;
+ is_variable: boolean;
+ currency: string;
+ rrule: string;
+ dtstart: IsoDate;
+ until: IsoDate | null;
+ business_day_shift: BusinessDayShift;
+ holiday_region: string;
+ installments_total: number | null;
+ principal_amount: Money | null;
+ contract_start: IsoDate | null;
+ contract_min_term_months: number | null;
+ contract_notice_period_days: number | null;
+ contract_auto_renew_months: number | null;
+ contract_cancelled_at: IsoDate | null;
+ reserve_enabled: boolean;
+ notes: string | null;
+ tags: string[];
+ is_active: boolean;
+ created_at: IsoDateTime;
+ updated_at: IsoDateTime;
+}
+
+export interface AmountVersion {
+ id: number;
+ recurrence_id: number;
+ amount: Money;
+ valid_from: IsoDate;
+ note: string | null;
+ created_at: IsoDateTime;
+}
+
+export interface ContractTerm {
+ term_start: IsoDate;
+ term_end: IsoDate;
+ notice_deadline: IsoDate | null;
+ renews_on: IsoDate | null;
+ is_cancelled: boolean;
+}
+
+export interface InstallmentStatus {
+ total: number;
+ paid: number;
+ remaining: number;
+ paid_amount: Money;
+ remaining_amount: Money;
+ final_due_date: IsoDate | null;
+}
+
+export interface RecurrenceDetail extends Recurrence {
+ merchant: Merchant | null;
+ amount_versions: AmountVersion[];
+ next_dates: IsoDate[];
+ monthly_reserve: Money | null;
+ annual_burden: Money;
+ contract_term: ContractTerm | null;
+ installments: InstallmentStatus | null;
+}
+
+export interface RecurrenceInput {
+ kind: EntryKind;
+ title: string;
+ merchant_id?: number | null;
+ category_id: number;
+ account_id: number;
+ amount: Money;
+ is_variable?: boolean;
+ currency?: string;
+ rrule: string;
+ dtstart: IsoDate;
+ until?: IsoDate | null;
+ business_day_shift?: BusinessDayShift;
+ holiday_region?: string;
+ installments_total?: number | null;
+ principal_amount?: Money | null;
+ contract_start?: IsoDate | null;
+ contract_min_term_months?: number | null;
+ contract_notice_period_days?: number | null;
+ contract_auto_renew_months?: number | null;
+ contract_cancelled_at?: IsoDate | null;
+ reserve_enabled?: boolean;
+ notes?: string | null;
+ tags?: string[];
+ is_active?: boolean;
+}
+
+/* --- Fälligkeiten --------------------------------------------------------- */
+
+export interface Occurrence {
+ recurrence_id: number;
+ recurrence_title: string;
+ kind: EntryKind;
+ category_id: number;
+ merchant_id: number | null;
+ account_id: number | null;
+ nominal_date: IsoDate;
+ due_date: IsoDate;
+ effective_date: IsoDate;
+ amount: Money;
+ actual_amount: Money | null;
+ effective_amount: Money;
+ status: OccurrenceStatus;
+ is_variable: boolean;
+ occurrence_id: number | null;
+ note: string | null;
+ installment_number: number | null;
+ installments_total: number | null;
+}
+
+export interface OccurrenceConfirmInput {
+ recurrence_id: number;
+ occurrence_date: IsoDate;
+ actual_amount?: Money | null;
+ actual_date?: IsoDate | null;
+ account_id?: number | null;
+ note?: string | null;
+}
+
+/* --- Buchungen ------------------------------------------------------------ */
+
+export interface Transaction {
+ id: number;
+ kind: EntryKind;
+ title: string;
+ merchant_id: number | null;
+ category_id: number;
+ account_id: number;
+ amount: Money;
+ booking_date: IsoDate;
+ note: string | null;
+ tags: string[];
+ created_at: IsoDateTime;
+ merchant: Merchant | null;
+}
+
+export interface TransactionInput {
+ kind: EntryKind;
+ title: string;
+ merchant_id?: number | null;
+ category_id: number;
+ account_id: number;
+ amount: Money;
+ booking_date: IsoDate;
+ note?: string | null;
+ tags?: string[];
+}
+
+/* --- Auswertungen --------------------------------------------------------- */
+
+export interface Totals {
+ income: Money;
+ expenses: Money;
+ balance: Money;
+}
+
+export interface MonthReport {
+ month: IsoDate;
+ planned: Totals;
+ actual: Totals;
+ previous_planned: Totals;
+ previous_actual: Totals;
+ delta_to_previous: Totals;
+ fixed_costs: Money;
+ variable_costs: Money;
+ reserves: Money;
+ available_after_fixed: Money;
+ confirmed_count: number;
+ open_count: number;
+ skipped_count: number;
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..cab29cc
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,58 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ // Umschaltbar über die Klasse `dark` am -Element; Vorgabe ist dunkel.
+ darkMode: "class",
+ theme: {
+ extend: {
+ colors: {
+ // Die Palette wird über CSS-Variablen gespeist, damit der Wechsel
+ // zwischen hell und dunkel ohne doppelte Klassen auskommt.
+ ground: "rgb(var(--color-ground) / )",
+ surface: "rgb(var(--color-surface) / )",
+ raised: "rgb(var(--color-raised) / )",
+ line: "rgb(var(--color-line) / )",
+ ink: "rgb(var(--color-ink) / )",
+ muted: "rgb(var(--color-muted) / )",
+ faint: "rgb(var(--color-faint) / )",
+ accent: "rgb(var(--color-accent) / )",
+ "accent-ink": "rgb(var(--color-accent-ink) / )",
+ positive: "rgb(var(--color-positive) / )",
+ negative: "rgb(var(--color-negative) / )",
+ warning: "rgb(var(--color-warning) / )",
+ },
+ fontFamily: {
+ sans: [
+ "Inter",
+ "system-ui",
+ "-apple-system",
+ "Segoe UI",
+ "Roboto",
+ "Helvetica Neue",
+ "sans-serif",
+ ],
+ mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
+ },
+ borderRadius: {
+ card: "0.875rem",
+ },
+ keyframes: {
+ "fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
+ "slide-up": {
+ from: { opacity: "0", transform: "translateY(0.5rem)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "slide-in-right": {
+ from: { transform: "translateX(100%)" },
+ to: { transform: "translateX(0)" },
+ },
+ },
+ animation: {
+ "fade-in": "fade-in 150ms ease-out",
+ "slide-up": "slide-up 180ms ease-out",
+ "slide-in-right": "slide-in-right 220ms cubic-bezier(0.22, 1, 0.36, 1)",
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..8f97a6f
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": false,
+
+ "types": ["node", "vitest/globals", "@testing-library/jest-dom"],
+ "baseUrl": ".",
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src", "vite.config.ts", "eslint.config.js"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..c4b8aca
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,32 @@
+import path from "node:path";
+
+import react from "@vitejs/plugin-react";
+// `vitest/config` erweitert Vites defineConfig um den Testabschnitt.
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { "@": path.resolve(__dirname, "./src") },
+ },
+ server: {
+ port: 5173,
+ // Im Entwicklungsbetrieb übernimmt Vite die Rolle, die in Produktion nginx hat.
+ proxy: {
+ "/api": {
+ target: process.env.VITE_API_PROXY ?? "http://localhost:8000",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: false,
+ },
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ css: false,
+ },
+});