commit 2105518e850d32d0cec7ff76a4fc0580871ca522 Author: peet Date: Thu Aug 28 14:24:06 2025 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faa2999 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# --- OS / Editor cruft --- +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.swp +*.swo +*~ +*.orig +*.rej + +# --- Env files (keep examples tracked) --- +**/.env +**/.env.* +!**/.env.example +!**/.env.local.example + +# --- Node / Next.js --- +**/node_modules/ +**/.next/ +**/out/ +**/.vercel/ +**/.turbo/ +**/.eslintcache +**/.pnp +**/.pnp.js +**/coverage/ +**/.nyc_output/ +**/*.tsbuildinfo +**/.cache/ +**/.parcel-cache/ +**/dist/ + +# --- Rust --- +**/target/ +# (Keep Cargo.lock in repo for binaries; default is tracked) + +# --- IDEs / project files --- +.vscode/ +.vscode-test/ +.idea/ +*.iml +*.code-workspace +.history/ + +# --- Logs --- +logs/ +**/*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# --- Local data / dumps --- +**/*.dump +**/*.backup diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..948add3 --- /dev/null +++ b/Makefile @@ -0,0 +1,123 @@ +# ========= Bacchus Monorepo Makefile (ohne Docker) ========= +# Variablen (bei Bedarf per "make VAR=wert ..." überschreiben) +DB_HOST ?= localhost +DB_PORT ?= 5432 +DB_NAME ?= bacchus +DB_USER ?= bacchus +DB_PASS ?= bacchus +SUPERUSER ?= postgres # Postgres-Superuser zum Anlegen von User/DB +PGPASSWORD_SUPER ?= # Passwort des SUPERUSER (leer, wenn nicht benötigt) + +DB_URL := postgres://$(DB_USER):$(DB_PASS)@$(DB_HOST):$(DB_PORT)/$(DB_NAME) + +# Pfade +BACKEND := apps/backend +FRONTEND := apps/frontend + +# ------------------------------------------------------------ +# Hilfs-Target: Hilfe ausgeben +# ------------------------------------------------------------ +.PHONY: help +help: ## Zeigt diese Hilfe + @echo "Bacchus Makefile – Targets:" + @awk 'BEGIN {FS":.*##"; printf "\n"} /^[a-zA-Z0-9_%-]+:.*##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } /^## / { printf "\n\033[1m%s\033[0m\n", substr($$0,4)}' $(MAKEFILE_LIST) + @echo "\nDB_URL = $(DB_URL)" + +## --- Umgebung/Envs ---------------------------------------------------- +.PHONY: env-backend +env-backend: ## apps/backend/.env aus Beispiel erzeugen (falls fehlt) + @test -f $(BACKEND)/.env || cp $(BACKEND)/.env.example $(BACKEND)/.env + +.PHONY: env-frontend +env-frontend: ## apps/frontend/.env.local aus Beispiel erzeugen (falls fehlt) + @test -f $(FRONTEND)/.env.local || cp $(FRONTEND)/.env.local.example $(FRONTEND)/.env.local + +## --- Datenbank einrichten --------------------------------------------- +.PHONY: db-create-user +db-create-user: ## DB-User $(DB_USER) anlegen (mit SUPERUSER) + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -tc "SELECT 1 FROM pg_roles WHERE rolname='$(DB_USER)';" | grep -q 1 || \ + PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "CREATE USER $(DB_USER) WITH PASSWORD '$(DB_PASS)';" + @echo "✔ User $(DB_USER) ok" + +.PHONY: db-create-db +db-create-db: ## Datenbank $(DB_NAME) anlegen (Owner=$(DB_USER)) + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -tc "SELECT 1 FROM pg_database WHERE datname='$(DB_NAME)';" | grep -q 1 || \ + PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "CREATE DATABASE $(DB_NAME) OWNER $(DB_USER);" + @echo "✔ DB $(DB_NAME) ok" + +.PHONY: db-setup +db-setup: db-create-user db-create-db ## User & DB anlegen (idempotent) + +.PHONY: db-psql +db-psql: ## Mit psql auf DB verbinden + psql "$(DB_URL)" + +.PHONY: db-drop-db +db-drop-db: ## Datenbank $(DB_NAME) löschen (Vorsicht!) + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$(DB_NAME)';" || true + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "DROP DATABASE IF EXISTS $(DB_NAME);" + @echo "✖ DB $(DB_NAME) dropped" + +.PHONY: db-drop-user +db-drop-user: ## DB-User $(DB_USER) löschen (Vorsicht!) + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "DROP OWNED BY $(DB_USER) CASCADE;" || true + @PGPASSWORD=$(PGPASSWORD_SUPER) psql -U $(SUPERUSER) -h $(DB_HOST) -p $(DB_PORT) -c "DROP ROLE IF EXISTS $(DB_USER);" + @echo "✖ User $(DB_USER) dropped" + +.PHONY: db-reset +db-reset: db-drop-db db-create-db migrate seed ## DB leeren & neu aufsetzen (Migrate+Seed) + +## --- Migrations (sqlx-cli benötigt) ----------------------------------- +.PHONY: migrate +migrate: ## Migrationen ausführen + cd $(BACKEND) && DATABASE_URL=$(DB_URL) sqlx migrate run + +.PHONY: migrate-new +migrate-new: ## Neue Migration anlegen: make migrate-new name=add_table + @test -n "$(name)" || (echo "Bitte mit name=... aufrufen"; exit 1) + cd $(BACKEND) && sqlx migrate add -r $(name) + +.PHONY: sqlx-prepare +sqlx-prepare: ## SQLx offline-data erzeugen (schnellere Builds) + cd $(BACKEND) && DATABASE_URL=$(DB_URL) cargo sqlx prepare + +## --- Seeds ------------------------------------------------------------- +.PHONY: seed +seed: ## Beispiel-Daten einspielen + psql "$(DB_URL)" -f scripts/seed.sql + +## --- Backend ----------------------------------------------------------- +.PHONY: dev-backend +dev-backend: env-backend ## Backend starten (Axum) + cd $(BACKEND) && RUST_LOG=bacchus=debug,axum=info DATABASE_URL=$(DB_URL) cargo run + +.PHONY: fmt +fmt: ## Rust & Frontend format/lint + cd $(BACKEND) && cargo fmt --all + @cd $(FRONTEND) && npm run lint || true + +.PHONY: clippy +clippy: ## Rust Lint (clippy) + cd $(BACKEND) && cargo clippy -- -D warnings + +.PHONY: test +test: ## Rust Tests + cd $(BACKEND) && cargo test + +## --- Frontend ---------------------------------------------------------- +.PHONY: dev-frontend +dev-frontend: env-frontend ## Frontend Dev-Server starten (Next.js) + cd $(FRONTEND) && npm install && npm run dev + +## --- Convenience -------------------------------------------------------- +.PHONY: dev +dev: ## Hinweis: Backend & Frontend in separaten Terminals starten (dev-backend, dev-frontend) + @echo "Starte getrennt:" + @echo " make dev-backend # API auf http://localhost:8080" + @echo " make dev-frontend # UI auf http://localhost:3000" + +.PHONY: clean +clean: ## Build-Artefakte entfernen + cargo clean || true + rm -rf $(FRONTEND)/.next $(FRONTEND)/node_modules || true + @echo "✔ Clean" diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..542e1ad --- /dev/null +++ b/Readme.md @@ -0,0 +1,15 @@ +# Bacchus 🍷 +WG-Getränke-Buchungssystem + +## Start ohne Docker + +### Voraussetzungen +- Rust (stable) +- Node.js (LTS) +- PostgreSQL installiert und läuft lokal auf Port 5432 +- sqlx-cli (`cargo install sqlx-cli --no-default-features --features rustls,postgres`) + +### Datenbank anlegen +```bash +psql -U postgres -c "CREATE USER bacchus WITH PASSWORD 'bacchus';" +psql -U postgres -c "CREATE DATABASE bacchus OWNER bacchus;" diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..3252c3e --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/bacchus +JWT_SECRET= +RUST_LOG=bacchus=debug,axum=info,tower_http=info \ No newline at end of file diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock new file mode 100644 index 0000000..077b67c --- /dev/null +++ b/apps/backend/Cargo.lock @@ -0,0 +1,2497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bacchus" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.6", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml new file mode 100644 index 0000000..e47330d --- /dev/null +++ b/apps/backend/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bacchus" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8.4", features = ["macros","json"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls","postgres","macros","uuid","chrono","migrate"] } +uuid = { version = "1", features = ["serde","v4"] } +chrono = { version = "0.4", features = ["serde"] } +tower-http = { version = "0.6.6", features = ["cors","trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" \ No newline at end of file diff --git a/apps/backend/migrations/0001_init.sql b/apps/backend/migrations/0001_init.sql new file mode 100644 index 0000000..d273548 --- /dev/null +++ b/apps/backend/migrations/0001_init.sql @@ -0,0 +1,63 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + price_cents INT NOT NULL CHECK (price_cents >= 0), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + amount_cents INT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('topup','purchase','adjustment')), + ref_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + total_cents INT NOT NULL CHECK (total_cents >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE order_items ( + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id), + qty INT NOT NULL CHECK (qty > 0), + price_cents INT NOT NULL CHECK (price_cents >= 0), + PRIMARY KEY (order_id, product_id) +); + +CREATE TABLE inventory_movements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id), + qty INT NOT NULL, + reason TEXT NOT NULL CHECK (reason IN ('purchase','consumption','correction')), + note TEXT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE VIEW user_balances AS +SELECT user_id, COALESCE(SUM(amount_cents),0) AS balance_cents +FROM ledger GROUP BY user_id; + +CREATE VIEW product_stock AS +SELECT p.id AS product_id, COALESCE(SUM(m.qty),0) AS stock +FROM products p +LEFT JOIN inventory_movements m ON m.product_id = p.id +GROUP BY p.id; diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs new file mode 100644 index 0000000..43c1830 --- /dev/null +++ b/apps/backend/src/main.rs @@ -0,0 +1,14 @@ +use axum::{ + routing::get, + Router, +}; + +#[tokio::main] +async fn main() { + // build our application with a single route + let app = Router::new().route("/", get(|| async { "Hello, World!" })); + + // run our app with hyper, listening globally on port 3000 + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} \ No newline at end of file diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000..5629131 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8080 diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/apps/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..f8e5455 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Bacchus + + +
+ + + diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json new file mode 100644 index 0000000..1b5278c --- /dev/null +++ b/apps/frontend/package-lock.json @@ -0,0 +1,2168 @@ +{ + "name": "bacchus-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bacchus-ui", + "version": "0.1.0", + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-icons": "^5.5.0", + "react-router-dom": "^7.8.2", + "recharts": "^3.1.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", + "vite": "^7.0.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.34", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", + "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz", + "integrity": "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz", + "integrity": "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz", + "integrity": "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz", + "integrity": "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz", + "integrity": "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz", + "integrity": "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz", + "integrity": "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz", + "integrity": "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz", + "integrity": "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz", + "integrity": "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz", + "integrity": "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz", + "integrity": "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz", + "integrity": "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz", + "integrity": "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz", + "integrity": "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz", + "integrity": "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz", + "integrity": "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz", + "integrity": "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz", + "integrity": "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz", + "integrity": "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@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, + "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, + "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, + "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, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "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.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "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/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", + "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.3", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.34", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "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" + } + ], + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "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/browserslist": { + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "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" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "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" + } + ] + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "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 + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/electron-to-chromium": { + "version": "1.5.211", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", + "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "dev": true + }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "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/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, + "optional": true, + "peer": true, + "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==", + "dev": 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, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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 + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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 + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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 + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, + "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==", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", + "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", + "license": "MIT", + "dependencies": { + "react-router": "7.8.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.1.2.tgz", + "integrity": "sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", + "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.49.0", + "@rollup/rollup-android-arm64": "4.49.0", + "@rollup/rollup-darwin-arm64": "4.49.0", + "@rollup/rollup-darwin-x64": "4.49.0", + "@rollup/rollup-freebsd-arm64": "4.49.0", + "@rollup/rollup-freebsd-x64": "4.49.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", + "@rollup/rollup-linux-arm-musleabihf": "4.49.0", + "@rollup/rollup-linux-arm64-gnu": "4.49.0", + "@rollup/rollup-linux-arm64-musl": "4.49.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", + "@rollup/rollup-linux-ppc64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-musl": "4.49.0", + "@rollup/rollup-linux-s390x-gnu": "4.49.0", + "@rollup/rollup-linux-x64-gnu": "4.49.0", + "@rollup/rollup-linux-x64-musl": "4.49.0", + "@rollup/rollup-win32-arm64-msvc": "4.49.0", + "@rollup/rollup-win32-ia32-msvc": "4.49.0", + "@rollup/rollup-win32-x64-msvc": "4.49.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "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, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "dev": true, + "license": "MIT" + }, + "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==" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "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": "7.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", + "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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_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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..c15e61d --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "bacchus-ui", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-icons": "^5.5.0", + "react-router-dom": "^7.8.2", + "recharts": "^3.1.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", + "vite": "^7.0.4" + } +} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/frontend/public/adelholznernaturell_img.jpg b/apps/frontend/public/adelholznernaturell_img.jpg new file mode 100644 index 0000000..ec53652 Binary files /dev/null and b/apps/frontend/public/adelholznernaturell_img.jpg differ diff --git a/apps/frontend/public/avatar-default.png b/apps/frontend/public/avatar-default.png new file mode 100644 index 0000000..9cd19d6 Binary files /dev/null and b/apps/frontend/public/avatar-default.png differ diff --git a/apps/frontend/public/background.jpg b/apps/frontend/public/background.jpg new file mode 100644 index 0000000..6c14cc9 Binary files /dev/null and b/apps/frontend/public/background.jpg differ diff --git a/apps/frontend/public/bg-cart.png b/apps/frontend/public/bg-cart.png new file mode 100644 index 0000000..d782b9b Binary files /dev/null and b/apps/frontend/public/bg-cart.png differ diff --git a/apps/frontend/public/bg-products.png b/apps/frontend/public/bg-products.png new file mode 100644 index 0000000..d3a9b10 Binary files /dev/null and b/apps/frontend/public/bg-products.png differ diff --git a/apps/frontend/public/jumpscare.webp b/apps/frontend/public/jumpscare.webp new file mode 100644 index 0000000..42d8192 Binary files /dev/null and b/apps/frontend/public/jumpscare.webp differ diff --git a/apps/frontend/public/logo_glass.svg b/apps/frontend/public/logo_glass.svg new file mode 100644 index 0000000..b902f48 --- /dev/null +++ b/apps/frontend/public/logo_glass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/logo_grape.svg b/apps/frontend/public/logo_grape.svg new file mode 100644 index 0000000..1d33e73 --- /dev/null +++ b/apps/frontend/public/logo_grape.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/no-image_img.jpg b/apps/frontend/public/no-image_img.jpg new file mode 100644 index 0000000..ec53652 Binary files /dev/null and b/apps/frontend/public/no-image_img.jpg differ diff --git a/apps/frontend/public/spezi_img.jpg b/apps/frontend/public/spezi_img.jpg new file mode 100644 index 0000000..8666fb7 Binary files /dev/null and b/apps/frontend/public/spezi_img.jpg differ diff --git a/apps/frontend/public/vite.svg b/apps/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx new file mode 100644 index 0000000..316304a --- /dev/null +++ b/apps/frontend/src/App.jsx @@ -0,0 +1,71 @@ +import React from "react"; +import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom"; +import PinLoginPage from "./components/PinLoginPage"; +import Order from "./components/Order"; +import ManagementLoginPage from "./components/ManagementLoginPage"; +import ManagementRoutes from "./management/ManagementRoutes"; +import { getCurrentUser } from "./api"; + +function useAuth() { + const [user, setUser] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(""); + React.useEffect(() => { + let active = true; + (async () => { + try { + const u = await getCurrentUser(); + if (active) setUser(u); + } catch { + if (active) setUser(null); + } finally { + if (active) setLoading(false); + } + })(); + return () => { active = false; }; + }, []); + return { user, loading, error, setUser }; +} + +function LoadingScreen({ label = "Lade..." }) { + return
{label}
; +} + +function RequireAuth({ children }) { + const { user, loading } = useAuth(); + const location = useLocation(); + if (loading) return ; + if (!user) return ; + return React.cloneElement(children, { user }); +} + +function RequireRole({ roles = ["admin", "manager"], children }) { + const { user, loading } = useAuth(); + const location = useLocation(); + if (loading) return ; + if (!user) return ; + const role = (user.role || "user").toLowerCase(); + if (!roles.map((r) => r.toLowerCase()).includes(role)) return ; + return React.cloneElement(children, { currentUser: user }); +} + +export default function App() { + return ( + + + } /> + } /> + } /> + + + + } + /> + } /> + + + ); +} diff --git a/apps/frontend/src/api.js b/apps/frontend/src/api.js new file mode 100644 index 0000000..cc6b249 --- /dev/null +++ b/apps/frontend/src/api.js @@ -0,0 +1,457 @@ +export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000"; + +/* ===================== Cookies & CSRF ===================== */ + +const API = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; + +function getCookie(name) { + const re = new RegExp( + "(?:^|; )" + + name.replace(/([.$?*|{}()[\]\\\/^])/g, "\\$1") + + "=([^;]*)" + ); + const m = document.cookie.match(re); + return m ? decodeURIComponent(m[1]) : null; +} + +// Holt CSRF +async function getCsrfMaybe() { + const fromCookie = getCookie("bacchus_csrf"); + if (fromCookie) return fromCookie; + const res = await fetch(`${API_BASE}/auth/csrf`, { + method: "GET", + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error("CSRF holen fehlgeschlagen"); + const token = getCookie("bacchus_csrf"); + if (!token) throw new Error("CSRF-Cookie fehlt"); + return token; +} + +// +export async function getCsrfToken() { + return getCsrfMaybe(); +} + +/* ===================== Request-Helper ===================== */ + +export async function request(path, options = {}) { + const url = `${API_BASE}${path}`; + const method = (options.method || "GET").toUpperCase(); + + const headers = { + Accept: "application/json", + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }; + + + headers["X-CSRF-Token"] = await getCsrfMaybe(); + + const res = await fetch(url, { + ...options, + method, + headers, + credentials: "include", + }); + + if (res.status === 204) return null; + + const text = await res.text().catch(() => ""); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + /* Hi */ + } + } + + if (!res.ok) { + const msg = (data && (data.detail || data.message)) || text || `Fehler ${res.status}`; + const err = new Error(msg); + err.status = res.status; + err.response = res; + throw err; + } + return data; +} + +export async function getJson(path, params) { + const qs = + params && typeof params === "object" + ? "?" + + Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join("&") + : ""; + return request(`${path}${qs}`, { method: "GET" }); +} + +/* ===================== Auth / Session ===================== */ + +export function loginPin(pin) { + return request("/auth/pin-login", { + method: "POST", + body: JSON.stringify({ pin }), + }); +} +export const loginWithPin = loginPin; + +export function loginManagement(email, password) { + return request("/auth/management-login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); +} + +export function logout() { + return request("/auth/logout", { method: "POST" }); +} + +export function getCurrentUser() { + return request("/auth/me"); +} + +export function getCapabilities() { + return request("/auth/capabilities"); +} + +/* ===================== Dashboard ===================== */ + +export function getStatsSummary() { + return request("/stats/summary"); +} + +/* ===================== Users & Favorites ===================== */ + +// Parametrische Liste (Suche/Filter/Sort/Paging) +export function listUsers(params = {}) { + return getJson("/users/", params); // trailing slash beibehalten +} + +export function getUsers() { + return listUsers(); +} + +export function getUserById(userId) { + return request(`/users/${userId}`); +} + +export function createUser(payload) { + return request("/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateUser(userId, patch) { + return request(`/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export function deleteUser(userId) { + return request(`/users/${userId}`, { method: "DELETE" }); +} + +// Sicherheitsaktionen (Admin/Manager) +export function setUserPin(userId, pin) { + return request(`/users/${userId}/set-pin`, { + method: "POST", + body: JSON.stringify({ pin }), + }); +} + +export function setUserPassword(userId, password) { + return request(`/users/${userId}/set-password`, { + method: "POST", + body: JSON.stringify({ password }), + }); +} + +export function adjustUserBalance(userId, amount_cents, reason) { + return request(`/users/${userId}/adjust-balance`, { + method: "POST", + body: JSON.stringify({ amount_cents, reason }), + }); +} + +export function getFavorites(userId) { + return request(`/users/${userId}/favorites`); +} + +export function updateFavorites(userId, favorites) { + const arr = Array.isArray(favorites) ? favorites.map((n) => (Number(n) | 0)) : []; + return request(`/users/${userId}/favorites`, { + method: "PATCH", + body: JSON.stringify(arr), + }); +} + +export function replaceFavorites(userId, favorites) { + const arr = Array.isArray(favorites) ? favorites.map((n) => (Number(n) | 0)) : []; + return request(`/users/${userId}/favorites`, { + method: "PUT", + body: JSON.stringify(arr), + }); +} + +/* ===================== Profile / Avatar ===================== */ + +export async function uploadAvatar(file) { + const form = new FormData(); + form.append("file", file); + + const res = await fetch(`${API_BASE}/profile/avatar`, { + method: "POST", + body: form, + credentials: "include", + headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen + }); + if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`); + return res.json(); +} + +export function updateOwnProfile(patch) { + return request("/profile/me", { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function requestNewPin() { + return request("/profile/request-new-pin", { method: "POST" }); +} + +/* ===================== Products ===================== */ + +export function getProducts() { + return request("/products/"); // trailing slash vermeidet 307 +} + +export function getProduct(productId) { + return request(`/products/${productId}`); +} + +export function createProduct(payload) { + return request("/products/", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateProduct(productId, patch) { + return request(`/products/${productId}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function deleteProduct(productId) { + return request(`/products/${productId}`, { method: "DELETE" }); +} + +/* ===================== Bookings ===================== */ + +export function createBooking(userId, productId, amount, totalCents, comment = null) { + const payload = { + user_id: Number(userId), + product_id: Number(productId), + amount: Number(amount), + total_cents: Number(totalCents), + ...(comment ? { comment } : {}), + }; + return request("/bookings/", { method: "POST", body: JSON.stringify(payload) }); +} + +export function getMyBookings({ limit = 100, offset = 0 } = {}) { + return getJson("/bookings/me", { limit, offset }); +} + +export function listBookings({ user_id, limit = 10, offset = 0 } = {}) { + return getJson("/bookings/", { user_id, limit, offset }); +} + + +/* ===================== Deliveries (Manager/Admin) ===================== */ + +export function getDeliveries() { + return request("/deliveries/"); +} +export function getDelivery(id) { + return request(`/deliveries/${id}`); +} +export function createDelivery(payload) { + return request("/deliveries/", { method: "POST", body: JSON.stringify(payload) }); +} +export function deleteDelivery(id) { + return request(`/deliveries/${id}`, { method: "DELETE" }); +} + +/* ===================== Topups (Manager/Admin) ===================== */ + +export function patchTopupStatus(topupId, status) { + return request(`/topups/${topupId}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); +} + +export function getMyTopups({ limit = 100, offset = 0 } = {}) { + return getJson("/topups/me", { limit, offset }); +} + +/* ===================== Stats ===================== */ + +export function getConsumptionPerUser() { + return request("/stats/consumption-per-user"); +} + +export function getConsumptionPerProduct() { + return request("/stats/consumption-per-product"); +} + +export function getMonthlyRanking({ year, month, limit = 10 } = {}) { + return getJson("/stats/monthly-ranking", { year, month, limit }); +} + +/* ===================== Audit / Transactions ===================== */ + +// Audit-Logs (Admin) +export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) { + return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to }); + } + +// Eigene Transaktionen +export function getMyTransactions({ limit = 100, offset = 0 } = {}) { + return getJson("/transactions/me", { limit, offset }); +} + +// Admin: alle Transaktionen +export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) { + return getJson("/transactions", { limit, offset }); +} + +/* ===================== Categories (Manager/Admin) ===================== */ + +// Liste aller Kategorien (Array) +export function getCategories() { + return request("/categories/"); // trailing slash wie bei /products/ +} + +// Kategorie umbenennen: alle Produkte mit old_name -> new_name +export function renameCategory(oldName, newName) { + if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich"); + const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`; + return request(`/categories/rename${qs}`, { method: "PUT" }); +} + +// Kategorie löschen, optional umhängen (reassign_to) +// Wenn reassignTo null/undefined ist, wird category auf NULL gesetzt +export function deleteCategory(name, reassignTo = null) { + if (!name) throw new Error("name ist erforderlich"); + const params = new URLSearchParams({ name }); + if (reassignTo) params.set("reassign_to", reassignTo); + return request(`/categories/?${params.toString()}`, { method: "DELETE" }); +} + +/* ================== Transaktionen Tracker ==============*/ + +export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) { + return getJson("/ledger/me", { limit, offset, types }); +} + +export function createTopup(amount_cents, note = null) { + const body = { amount_cents: Number(amount_cents) }; + if (note) body.note = note; + return request("/topups/", { + method: "POST", + body: JSON.stringify(body), + }); +} + +// ===================== NEU: Stats (öffentlich, aber auth-pflichtig) ===================== +export const getStatsMeta = () => request("/stats/meta"); + +export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) { + return getJson("/stats/top-drinkers", { period, category, limit, tz }); +} + +export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) { + return getJson("/stats/product-share", { period, tz }); +} + + + +let CSRF = { token: null, header: "X-CSRF-Token", fetchedAt: 0 }; + +async function ensureCsrf() { + if (CSRF.token && Date.now() - CSRF.fetchedAt < 5 * 60 * 1000) return CSRF; + const res = await fetch(API + "/auth/csrf", { credentials: "include" }); + let data = {}; + try { data = await res.json(); } catch {} + CSRF.token = + data?.token || data?.csrf || data?.csrf_token || data?.value || null; + CSRF.header = + data?.header_name || data?.header || CSRF.header; // Server kann den Headernamen mitliefern + CSRF.fetchedAt = Date.now(); + if (!CSRF.token) throw new Error("CSRF token missing from /auth/csrf"); + return CSRF; +} + +async function req(path, opts = {}) { + const method = (opts.method || "GET").toUpperCase(); + const isMutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"; + + const headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; + + if (isMutating) { + const { token, header } = await ensureCsrf(); // <- WICHTIG + headers[header] = token; + } + + const res = await fetch(API + path, { + credentials: "include", + headers, + ...opts, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.status === 204 ? null : res.json(); +} + +export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) { + const qs = new URLSearchParams({ limit, offset }); + if (status_filter) qs.set("status_filter", status_filter); + if (user_id) qs.set("user_id", user_id); + return req(`/topups?${qs.toString()}`); +} + + + +export async function createTopupAdmin({ user_id, amount_cents, note = "" }) { + return req(`/topups`, { + method: "POST", + body: JSON.stringify({ user_id, amount_cents, note }), + }); +} + +export async function updateTopupStatus(id, status) { + return req(`/topups/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); +} + + +export async function getUsersLite({ limit = 200, offset = 0 } = {}) { + const lim = Math.min(Number(limit) || 200, 200); + return req(`/users?limit=${lim}&offset=${offset}`); +} diff --git a/apps/frontend/src/assets/react.svg b/apps/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/components/CartItem.jsx b/apps/frontend/src/components/CartItem.jsx new file mode 100644 index 0000000..89ea0a8 --- /dev/null +++ b/apps/frontend/src/components/CartItem.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { FaPlus, FaMinus } from 'react-icons/fa'; + +export default function CartItem({ item, onIncrement, onDecrement }) { + const product = item?.product || {}; + + return ( +
+
+

+ {product.name || 'Unbekanntes Produkt'} +

+

+ €{((product.price_cents ?? 0) / 100).toFixed(2).replace('.', ',')} +

+
+ +
+ + + {item.quantity} + + +
+
+ ); +} diff --git a/apps/frontend/src/components/Dashboard.jsx b/apps/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/components/ManagementLoginPage.jsx b/apps/frontend/src/components/ManagementLoginPage.jsx new file mode 100644 index 0000000..63c985c --- /dev/null +++ b/apps/frontend/src/components/ManagementLoginPage.jsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { loginManagement } from '../api'; + +export default function ManagementLoginPage() { + const [email, setEmail] = useState(''); + const [pw, setPw] = useState(''); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(''); + const navigate = useNavigate(); + + const canSubmit = email.trim().length > 3 && pw.length >= 6 && !loading; + + const onSubmit = async (e) => { + e.preventDefault(); + if (!canSubmit) return; + setLoading(true); + setErr(''); + try { + await loginManagement(email.trim(), pw); + // Session-Cookie ist nun gesetzt -> ins Management-Dashboard + navigate('/management', { replace: true }); + } catch (error) { + setErr(error.message || 'Login fehlgeschlagen'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + {/* Kopf mit SVGs aus /public */} +
+ Trauben +

BACCHUS

+ Glas +
+ + {/* Karte */} +
+

Management-Login

+ +
+
+ + setEmail(e.target.value)} + disabled={loading} + /> +
+ +
+ + setPw(e.target.value)} + disabled={loading} + /> +
+ + {err && ( +
+ {err} +
+ )} + + +
+ + {/* Sekundäre Aktionen */} +
+ + Zur PIN-Anmeldung + + + + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/Order.jsx b/apps/frontend/src/components/Order.jsx new file mode 100644 index 0000000..4b5fe70 --- /dev/null +++ b/apps/frontend/src/components/Order.jsx @@ -0,0 +1,400 @@ +import React, { + useEffect, useMemo, useState, useCallback, startTransition, useDeferredValue +} from "react"; +import { + getCurrentUser, getProducts, createBooking, getFavorites, + updateFavorites, logout, API_BASE, getCategories +} from "../api"; +import ProductCard from "./ProductCard"; +import CartItem from "./CartItem"; +import { useLocation, useNavigate } from "react-router-dom"; + +const DEFAULT_BG1 = "#0a6bff"; +const DEFAULT_BG2 = "#00e0aa"; + +function applyOrderColorsFromStorage() { + const c1 = localStorage.getItem("order_bg1") || DEFAULT_BG1; + const c2 = localStorage.getItem("order_bg2") || DEFAULT_BG2; + document.documentElement.style.setProperty("--order-bg1", c1); + document.documentElement.style.setProperty("--order-bg2", c2); +} + + +const PAGE_SIZE = 20; + +// deterministische Farbe je Kategorie +function hueFromString(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return (h >>> 0) % 360; +} + +// Pastell (inaktiv) vs. kräftig + Glow (aktiv) +function chipStyleFor(cat, active) { + const strong = (h) => ({ + bg: `hsl(${h} 85% 72%)`, + br: `hsla(${h} 65% 35% / .60)`, + glow: `0 0 0 5px hsla(${h} 95% 55% / .40), 0 0 22px hsla(${h} 95% 55% / .50)`, + fg: "#0b1220", + }); + const soft = (h) => ({ + bg: `hsl(${h} 65% 90%)`, + br: `hsla(${h} 40% 40% / .35)`, + glow: "none", + fg: "#0b1220", + }); + if (cat === "Alle") return active + ? { bg:"hsl(210 75% 74%)", br:"hsla(210 65% 35%/.6)", glow:"0 0 0 5px hsla(210 95% 55%/.4),0 0 22px hsla(210 95% 55%/.5)", fg:"#0b1220" } + : { bg:"hsl(210 55% 90%)", br:"hsla(210 40% 40%/.35)", glow:"none", fg:"#0b1220" }; + if (cat === "Favoriten") return active + ? { bg:"hsl(52 90% 72%)", br:"hsla(52 70% 35%/.6)", glow:"0 0 0 5px hsla(52 98% 50%/.4),0 0 22px hsla(52 98% 50%/.5)", fg:"#0b1220" } + : { bg:"hsl(52 80% 90%)", br:"hsla(52 55% 40%/.35)", glow:"none", fg:"#0b1220" }; + const h = hueFromString(cat); + return active ? strong(h) : soft(h); +} + +// robustes Aktiv-Flag (API kann bool/zahl/string liefern) +function isActiveProduct(p) { + const v = p?.is_active; + return v === true || v === 1 || v === "true" || v === "1"; +} + +const MemoProductCard = React.memo(ProductCard); + +export default function Order({ user: userProp }) { + const navigate = useNavigate(); + const location = useLocation(); + const stateUser = location.state?.user || null; + + const [user, setUser] = useState(userProp || stateUser || null); + const [products, setProducts] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState("Alle"); + const deferredCategory = useDeferredValue(selectedCategory); // INP + const [page, setPage] = useState(1); + + const [cart, setCart] = useState({}); + const [favorites, setFavorites] = useState(new Set()); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + const collator = useMemo(() => new Intl.Collator("de", { sensitivity: "base" }), []); + + useEffect(() => { + let cancelled = false; + + // Farben sofort anwenden + Listener registrieren + applyOrderColorsFromStorage(); + const onChange = (e) => { + const { bg1, bg2 } = e.detail || {}; + if (bg1) document.documentElement.style.setProperty("--order-bg1", bg1); + if (bg2) document.documentElement.style.setProperty("--order-bg2", bg2); + }; + window.addEventListener("order-bg-change", onChange); + + // Daten laden + (async () => { + setLoading(true); + try { + const [u, prod, cats] = await Promise.all([ + userProp || stateUser ? (userProp || stateUser) : getCurrentUser(), + getProducts(), + typeof getCategories === "function" ? getCategories() : Promise.resolve(null), + ]); + if (cancelled) return; + + setUser(u || null); + setProducts(Array.isArray(prod) ? prod : []); + setCategories( + cats && Array.isArray(cats) + ? cats + : Array.from(new Set((prod || []).map((p) => p.category).filter(Boolean))) + ); + + if (u?.id) { + try { + const f = await getFavorites(u.id); + if (!cancelled && Array.isArray(f)) setFavorites(new Set(f)); + } catch {} + } + } catch (e) { + if (!cancelled) setError(e?.message || "Initiale Daten konnten nicht geladen werden."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + // Cleanup des Effects + return () => { + cancelled = true; + window.removeEventListener("order-bg-change", onChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userProp, stateUser]); + + + const activeProducts = useMemo( + () => (products || []).filter(isActiveProduct), + [products] + ); + + const sortedProducts = useMemo(() => { + const arr = (activeProducts || []).slice(); + arr.sort((a, b) => collator.compare(a?.name || "", b?.name || "")); + return arr; + }, [activeProducts, collator]); + + const filtered = useMemo(() => { + if (deferredCategory === "Favoriten") { + return sortedProducts.filter((p) => favorites.has(p.id)); + } + if (deferredCategory === "Alle") return sortedProducts; + return sortedProducts.filter((p) => p.category === deferredCategory); + }, [sortedProducts, deferredCategory, favorites]); + + const onSelectCategory = useCallback((cat) => { + startTransition(() => { + setSelectedCategory(cat); + setPage(1); + }); + }, []); + +const DEFAULT_AVATAR = "/avatar-default.png"; + +const avatarSrc = useMemo(() => { + const raw = user?.avatar_url?.trim(); + if (!raw) return DEFAULT_AVATAR; + if (/^(https?:|data:|blob:|\/\/)/i.test(raw)) return raw; + if (raw === DEFAULT_AVATAR) return raw; + return `${API_BASE}${raw.startsWith("/") ? raw : `/${raw}`}`; +}, [user?.avatar_url, API_BASE]); + + const total = filtered.length; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const startIdx = (currentPage - 1) * PAGE_SIZE; + const endIdx = Math.min(startIdx + PAGE_SIZE, total); + const pageItems = useMemo( + () => filtered.slice(startIdx, endIdx), + [filtered, startIdx, endIdx] + ); + + const handleSelect = useCallback((product) => { + startTransition(() => { + setCart((prev) => { + const qty = prev[product.id]?.quantity || 0; + return { ...prev, [product.id]: { product, quantity: qty + 1 } }; + }); + }); + }, []); + const handleIncrement = useCallback((id) => + startTransition(() => { + setCart((prev) => ({ ...prev, [id]: { ...prev[id], quantity: prev[id].quantity + 1 } })); + }), []); + const handleDecrement = useCallback((id) => + startTransition(() => { + setCart((prev) => { + const item = prev[id]; + if (!item) return prev; + if (item.quantity <= 1) { + const { [id]: _, ...rest } = prev; + return rest; + } + return { ...prev, [id]: { ...item, quantity: item.quantity - 1 } }; + }); + }), []); + + const handleToggleFavorite = useCallback(async (id) => { + const before = new Set(favorites); + const next = new Set(favorites); + next.has(id) ? next.delete(id) : next.add(id); + setFavorites(next); + try { + if (!user?.id) throw new Error("Kein Nutzer geladen."); + await updateFavorites(user.id, [...next]); + } catch (e) { + setFavorites(before); + setError(e?.message || "Favoriten konnten nicht gespeichert werden."); + } + }, [favorites, user?.id]); + + const cartItems = useMemo(() => Object.values(cart), [cart]); + const cartTotalCents = useMemo( + () => cartItems.reduce((s, { product, quantity }) => s + product.price_cents * quantity, 0), + [cartItems] + ); + + const handlePay = useCallback(async () => { + const balance = user?.balance_cents ?? 0; + if (balance < cartTotalCents) { setError("Zu wenig Geld."); return; } + try { + for (const { product, quantity } of cartItems) { + await createBooking( + user.id, + product.id, + quantity, + product.price_cents * quantity + ); + } + try { + const refreshed = await getCurrentUser(); + if (refreshed) setUser(refreshed); + } catch {} + setCart({}); + try { await logout(); } finally { navigate("/", { replace: true }); } + } catch (e) { + setError(e?.message || "Bezahlen fehlgeschlagen."); + } + }, [cartItems, cartTotalCents, navigate, user?.balance_cents, user?.id]); + + const handleLogout = useCallback(async () => { + try { await logout(); } finally { navigate("/", { replace: true }); } + }, [navigate]); + + const effectiveBalanceCents = (user?.balance_cents ?? 0) - cartTotalCents; + const balance = (effectiveBalanceCents / 100).toFixed(2).replace(".", ","); + const balanceColor = effectiveBalanceCents <= 0 ? "text-red-400" : "text-green-400"; + + // Kategorien-UI aus aktiven Produkten ableiten (ohne useMemo) + const catsBase = (categories && categories.length) + ? categories + : Array.from(new Set((activeProducts || []).map(p => p.category).filter(Boolean))); + const catsForUI = ["Alle", ...catsBase, "Favoriten"]; + + if (loading) { + return
Lädt …
; + } + + return ( +
+ {/* Produktbereich */} +
+ {/* Kategorie-Chips */} +
+ {catsForUI.map((cat) => { + const active = selectedCategory === cat; + const c = chipStyleFor(cat, active); + return ( + + ); + })} +
+ {total === 0 ? "0" : `${startIdx + 1}`}–{endIdx} von {total} +
+
+ + {/* Grid */} +
+ {pageItems.map((p) => ( +
+ +
+ ))} + {pageItems.length === 0 && ( +
Keine Produkte gefunden.
+ )} +
+ + {/* Pagination */} +
+ + + {Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => ( + + ))} + + +
+
+ + {/* Warenkorb */} +
+
+
+ Profil { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }} + /> + + Willkommen {user?.name || user?.alias} + +
+ +
+
Kontostand:
+
€{balance}
+
+ +
+ {Object.values(cart).length === 0 &&
Warenkorb ist leer
} + {Object.values(cart).map((item) => ( + + ))} +
+ + {error &&
{error}
} +
+ Einkaufssumme: €{(cartTotalCents / 100).toFixed(2).replace(".", ",")} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/PinLoginPage.jsx b/apps/frontend/src/components/PinLoginPage.jsx new file mode 100644 index 0000000..cf6bbca --- /dev/null +++ b/apps/frontend/src/components/PinLoginPage.jsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { loginWithPin } from '../api'; + +export default function PinLoginPage() { + const [pin, setPin] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const maskChar = '•'; + const masked = pin.length ? maskChar.repeat(pin.length) : '\u00A0'; + + const push = (d) => { + if (loading) return; + setError(''); + setPin((p) => (p.length < 6 ? p + d : p)); + }; + const backspace = () => !loading && setPin((p) => p.slice(0, -1)); + const reset = () => { if (!loading) { setPin(''); setError(''); } }; + + const submit = async () => { + if (pin.length !== 6) { setError('PIN muss 6 Ziffern haben'); return; } + setLoading(true); + try { + const user = await loginWithPin(pin); + setPin(''); + // Sofort navigieren; Order lädt parallel alle Daten (Promise.all) + navigate('/order', { replace: true, state: { user } }); + } catch (e) { + setError(e.message || 'Login fehlgeschlagen'); + } finally { setLoading(false); } + }; + + useEffect(() => { + const onKey = (e) => { + if (/^[0-9]$/.test(e.key)) push(e.key); + else if (e.key === 'Backspace') backspace(); + else if (e.key === 'Enter') submit(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pin, loading]); + + return ( +
+ {/* Management-Link oben rechts */} + + {/* kleines Zahnrad als Inline-SVG */} + + + + + +
+ {/* Kopf mit SVGs aus /public */} +
+ Trauben +

BACCHUS

+ Glas +
+ + {/* Maskenfeld – feste Höhe */} +
+
+

+ {masked} +

+
+ {error &&
{error}
} +
+ + {/* Keypad */} +
+ {[ + ['1','2','3'], + ['4','5','6'], + ['7','8','9'], + ].map((row, i) => ( +
+ {row.map((n) => ( + + ))} +
+ ))} + + {/* Untere Reihe */} +
+ + + + + +
+ +
+ Hier maybe noch Impressum etc +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/ProductCard.jsx b/apps/frontend/src/components/ProductCard.jsx new file mode 100644 index 0000000..273bd31 --- /dev/null +++ b/apps/frontend/src/components/ProductCard.jsx @@ -0,0 +1,107 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { FaRegHeart, FaHeart } from 'react-icons/fa'; + +export default function ProductCard({ + product, + onSelect, + isFavorite, + onToggleFavorite, + count = 0, // Menge im Warenkorb +}) { + const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase(); + + // Fester Fallback (Datei MUSS in /public liegen und so heißen) + const FALLBACK_IMG = '/no-image_img.jpg'; + + // Letzter Fallback: eingebettetes SVG (funktioniert ohne Datei) + const FALLBACK_SVG = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + ` + + + Kein Bild + + ` + ); + + // Bild-Kandidaten (Reihenfolge = Priorität) + const candidates = useMemo( + () => + [ + product?.image_url, // absolute oder /public-URL + product?.image, // alternative Feldquelle + `/${baseName}_img.jpg`, // z.B. /spezi_img.jpg (in /public) + `/images/${baseName}.jpg`, // z.B. /images/spezi.jpg (in /public/images) + ].filter(Boolean), + [product, baseName] + ); + + // Aktueller Kandidaten-Index + Quelle + const [idx, setIdx] = useState(0); + const current = candidates[idx] || FALLBACK_IMG; + const [imgSrc, setImgSrc] = useState(current); + + // WICHTIG: Bei Kandidatenwechsel wieder von vorne anfangen + useEffect(() => { + setIdx(0); + setImgSrc(candidates[0] || FALLBACK_IMG); + }, [candidates]); + + const handleError = () => { + const nextIdx = idx + 1; + if (nextIdx < candidates.length) { + setIdx(nextIdx); + setImgSrc(candidates[nextIdx]); + } else if (imgSrc !== FALLBACK_IMG) { + setImgSrc(FALLBACK_IMG); + } else { + setImgSrc(FALLBACK_SVG); + } + }; + + return ( +
onSelect(product)} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)} + > + {product?.name + +
+

{product?.name}

+

+ {product?.volume_ml ? `${(product.volume_ml / 1000).toFixed(1)}L • ` : ''} + €{((product?.price_cents ?? 0) / 100).toFixed(2).replace('.', ',')} +

+
+ + {/* Favoriten-Button */} + + + {/* Mengen-Badge */} + {count > 0 && ( +
+ x{count} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/main.jsx b/apps/frontend/src/main.jsx new file mode 100644 index 0000000..d106f4f --- /dev/null +++ b/apps/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/apps/frontend/src/management/ManagementLayout.jsx b/apps/frontend/src/management/ManagementLayout.jsx new file mode 100644 index 0000000..1302a95 --- /dev/null +++ b/apps/frontend/src/management/ManagementLayout.jsx @@ -0,0 +1,275 @@ +import React, { useEffect, useState, Suspense, useMemo } from "react";; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { + FiCpu, + FiHome, + FiUsers, + FiPackage, + FiTruck, + FiBarChart2, + FiSettings, + FiLogOut, + FiActivity, + FiCalendar, + FiCreditCard, + FiList, +} from "react-icons/fi"; +import { logout, getCapabilities, API_BASE } from "../api"; + +/* ---------- Hintergrund ---------- */ +function BackgroundFX({ children }) { + return ( +
+
+
+ {children} +
+ ); +} + +/* ---------- Navigation ---------- */ +const NAV_ALL = [ + { cap: "dashboard", to: "/management", label: "Dashboard", icon: FiHome, end: true }, + { cap: "my-bookings", to: "/management/bookings", label: "Buchungen", icon: FiCalendar }, + { cap: "my-transactions", to: "/management/transaction", label: "Transaktion", icon: FiCreditCard }, + { cap: "system-config", to: "/management/settings", label: "Einstellungen", icon: FiSettings }, + { cap: "users", to: "/management/users", label: "Nutzer", icon: FiUsers }, + { cap: "products", to: "/management/products", label: "Produkte", icon: FiPackage }, + { cap: "deliveries", to: "/management/deliveries", label: "Lieferungen", icon: FiTruck }, + { cap: "stats-advanced", to: "/management/stats", label: "Statistiken", icon: FiBarChart2 }, + { cap: "transactions", to: "/management/transactions", label: "Transaktionen", icon: FiList }, + { cap: "audit-logs", to: "/management/logs", label: "Logs", icon: FiActivity }, +]; + +// Route-Chunks für Prefetch +const prefetchMap = { + "/management": () => import("./pages/DashboardPage"), + "/management/bookings": () => import("./pages/BookingsPage"), + "/management/transaction": () => import("./pages/TransactionPage"), + "/management/transactions":() => import("./pages/AdminTransactionsPage"), + "/management/users": () => import("./pages/UsersPage"), + "/management/products": () => import("./pages/ProductsPage"), + "/management/deliveries": () => import("./pages/DeliveriesPage"), + "/management/stats": () => import("./pages/StatsPage"), + "/management/logs": () => import("./pages/LogsPage"), + "/management/settings": () => import("./pages/SettingsPage"), +}; + +/* ---------- Sidebar ---------- */ +function Sidebar({ capabilities, collapsed, onToggle }) { + const navItems = NAV_ALL.filter((item) => capabilities.includes(item.cap)); + + return ( + + ); +} + +/* ---------- Topbar (mit Avatar aus Backend) ---------- */ +const DEFAULT_AVATAR = "/avatar-default.png"; + +function Topbar({ user, onLogout }) { + const avatarSrc = useMemo(() => { + const raw = (user?.avatar_url || "").trim(); + if (!raw) return DEFAULT_AVATAR; // kein Avatar gesetzt + if (/^(https?:|data:|blob:|\/\/)/i.test(raw)) return raw; // absolute/data/blob + // relative Backend-URL präfixen + return `${API_BASE}${raw.startsWith("/") ? raw : `/${raw}`}`; + }, [user?.avatar_url, API_BASE]); + + return ( +
+
+ +
+
+ {user?.name { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }} + referrerPolicy="no-referrer" + loading="lazy" + decoding="async" + className="w-8 h-8 rounded-full object-cover ring-1 ring-cyan-400/30" + /> +
+
+ {user?.name ?? "Admin"} +
+
+ {(user?.role ?? "manager").toString()} +
+
+
+ + +
+
+ ); +} + + +/* ---------- Layout-Hülle ---------- */ +export default function ManagementLayout({ currentUser }) { + const navigate = useNavigate(); + const [capabilities, setCapabilities] = useState([]); + const [loading, setLoading] = useState(true); + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + (async () => { + try { + const data = await getCapabilities(); + setCapabilities(Array.isArray(data.capabilities) ? data.capabilities : []); + } catch { + setCapabilities(["dashboard", "profile", "my-bookings"]); + } finally { + setLoading(false); + } + })(); + }, []); + + const handleLogout = async () => { + try { + await logout(); + } catch { + /* ignore */ + } finally { + navigate("/management-login", { replace: true }); + } + }; + + if (loading) { + return
Lade Managementbereich …
; + } + + return ( + +
+ setCollapsed((v) => !v)} + /> +
+ +
+ Lade…
}> + + + + +
+ + ); +} diff --git a/apps/frontend/src/management/ManagementRoutes.jsx b/apps/frontend/src/management/ManagementRoutes.jsx new file mode 100644 index 0000000..aff9193 --- /dev/null +++ b/apps/frontend/src/management/ManagementRoutes.jsx @@ -0,0 +1,143 @@ +import React, { lazy } from "react"; +import { Routes, Route } from "react-router-dom"; +import ManagementLayout from "./ManagementLayout"; + +// Route-Level Code-Splitting +const DashboardPage = lazy(() => import("./pages/DashboardPage")); +const BookingsPage = lazy(() => import("./pages/BookingsPage")); +const TransactionPage = lazy(() => import("./pages/TransactionPage")); +const AdminTransactionsPage = lazy(() => import("./pages/AdminTransactionsPage")); +const UsersPage = lazy(() => import("./pages/UsersPage")); +const ProductsPage = lazy(() => import("./pages/ProductsPage")); +const DeliveriesPage = lazy(() => import("./pages/DeliveriesPage")); +const StatsPage = lazy(() => import("./pages/StatsPage")); +const LogsPage = lazy(() => import("./pages/LogsPage")); +const SettingsPage = lazy(() => import("./pages/SettingsPage")); +const NotFoundPage = lazy(() => import("./pages/NotFoundPage")); + +export default function ManagementRoutes({ currentUser }) { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} +/* + + Champ + + + + + ***+++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@%*++======================================================================================+++***###%%%%###***++++=== + *++++++++++++++++++++++++++=====================++*%@@@@@@@@@@@@@@%*+======================================================================================++++***###%%%%###***++++=== + **++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#++==============================----===================================================+++++***#######****+++++=== + **++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#*+=================================---=================================================+++++*************+++++==== + **+++++++++++++++++++++++++++====================+*#@@@@@@@@@@@@@@@%*+=================================-===================================================+++++++++*******+++++++==== + +++++++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@@#+===================----===----=====----=================================================+++++++++++++++++++===== + +++++++++++++++++++++++++========================++*#%@@@@@@@@@@@@@@%*+================----===-----------------------------======================================+++++++++++++=+====== + +++++++++++++++++++++++++=========================+*#%%@@@@@@@@@@@@@%*+++============--------===--------------------------------------================================================ + ++++++++++++++++++++++++++========================+*#%@@@@@@@@@@@@@@@#*##*##=========---------------------=-----------------------==--================================================ + ++++++++++++++++++++++++++========================++*%@@@@@@@@@@@@@@@%#+**#+++======-----------------------------------------------=================================================== + ++++++++++++++++++++++============================++*%@@@@@@@@@@@@@@@@+****##*+=====------------------------------------------------=*@%#=---========================================= + +++++++++++++++++++++++++==========================+*##%%@@@@@@@@@@@@@*#*****%#*=====---------------------------------------------=+++*#*=------------================================ + +++++++++++++++++++++++++==========================++**#%%@@@@@@@@@@@@#%##****###+=-=-------------------------------------------=++*%##%#+=====-------================================ + +++++++++++++++++++++===============================++*#%@@@@@@@@@@@@##%###*++*%#%++=----------------------------------------==++*%#***###==-------=================================== + ++++++++++++++++++==+===============================++*#%@@@@@@@@@@@@##%####*+++*%%%*+==-----------------------------------==++#%%######@%==-------===-=============================== + ++++++++++++++++++++++===============================+*#%@@@@@@@@@@@@%*#***++**+*##%@**======---------------------------==+#*%@%%%##**#%@#==-----------======--======================= + +++++++++++++++++==+++===============================+*#%%@@@@@@@@@@@@*#****+++++*###@%#@##*+==--------=-----------=====#%*%%%%#+*+*#%%%@*---=====-------============================= + +++++++++++++++++====================================++*#%%%%@@@@@@@@@#***++=====+*##%@##@@@@##**++====+===-=====++%%%%%+@@%%%#*##**%%@@%==--------------============================= + ++++++++++++++========================================+*##%%%@@@@@@@@@@***++=====++*%@@@%#@@@###@%*%%%%%@##%**%*#@%@@@*%@@@#***+***#*%%@*------------------=========================== + +++++==+++++++========================================++*#%%@@@@@@@@@@@***#*++++++*###@@@*@@@@#%@%+#%%@@@***%@##@@@@@+@@@%#**++*++*##%%#=--------------------========================= + ++++===================================================+*#%%%%@@@@@@@@@@%****+++**##%%@@@%@@%%#+%%@@@@@@@@@+%%%@@@@@*%@@@@%*+++++++#%%%+-----------------------======================= + +++++++++==============================================+**#%%%%@@@@@@@@@#+*##***#*****#%#%##*@%@@#%@@%@%@@%@@@*%%@@%%@@@@@%#*******###+----------------==------======================= + +++++++++==============================================++*#%%%%@@@@@@@@%+*#%%@%*++++++#%%%%@@@@@%@###@@@#@#@@@@@%#%%#%%%%@@%@%#%@@%#@@#----------------------========================= + ++++++++================================================+*#%%%@@@@@@@@@#+#%%%##+=++*###%@%%%@@@@@*#@@@@@@@#%@@@@@@@#@###**#%%%@@@@@@#@*----------------------========================= + +++++++++===============================================++*#%@@@@@@@@@@%+#@@#+==+**#%%##%%%%#%@@@*#@@@@@@%**@@@%%#@@@%%##****#@@@@@@##=------------------------======================= + +++++++++=======================================------===+*#%@@@@@@@@@@@%%@%**+**#%#%##%##%#%@@@@#+#%@@@@*#@@@%@#%#%%%%@@%##***%@@@@#+=------------------------======================= + +++++==============================================--====+*#%@@@@@@@%%##%%*#*#%#######%%@##%%%#@@%*%@@@@@+*@@%%@*#%%*#%%%@@%#*##@@@@#==------------------------======================= + +++=++==============================---==-------=========+*#%@@@@@@%%#*#%#*******#%%%@%@%%+%@#*@@@##@@@@@*@@#+%@##%%@%%*%@@@%%%%%@@@%+=------------------------======================= + ++++============================--------------------=====+*#@@@@@%###****#+=+*###%%%#%@#++*%@%**@@#@@@@@#@@@**@@%+*##@@@%*#@%@%%%%%@@+---------------------------===================== + +++=============================----------------------===+*%@@@@%####++++==+*##**+++*+*#*=+@@#++@@@@@@@@%@@#+#@@#+*%%@###***%##*%@@%%#=--------------------------===================== + ++===========================-------------------------==+*#@@@%%%##%*++*++=+#*##*++*+***+++@@#++%%%@@@@%%%%*+%@@+++*#**+**##%#**#%%#%#+=----------------------------================== + ++========================----------------------------=+*#@@@%%%###%+*+++*%@@@@%%@@@@@@@@@**%%#####%%@%#**##%@@%*+**#@%#***#@%%#*%%%#*=-----------------------------================== + ++========================---------------------------==+#%@@@%%###****++#%@%#**++*@%***@@@@@#+*##*##%%%#%%@@@@%%@@@@@@@@@@@%@%@%%#@%##+-------------------------------================ + ++=====================-----------------------------==+#%@@@@%%%%###*++#@@#+++=+==@@***%@%%#@#==**###%%%%@%##@@@@@@@@@@@@#*%@@@@@@%%%#*=----------------------------------============ + ======================-----------------------------==+#%@@@@%%#%%%###+%@@+=+++**+==@@#**#**@@@=-++***###%%++@@@@@@@@%%%@+=+=++*%@@@%%#*=----------------------------------============ + +====================-----------------------------==+#%@@@@@%########%%*++++*###*++==*@@@@@@@@--==****#*++=+@@@@@%%%%@%==++**+#@@@@@%%*=------------------------------------========== + =====================-----------------------------=+*%@@@@%%########%#+===++++*#***+**+=---=*%+=++++*****+++@%****#+===+*####@#*%@@@@#==------------------------------================ + =====================----------------------------==*#@@@@%%########%*+=+====++++++*+=+***+=+@@#**+++++*###%@@*=--=++#+*#%%#%###%%%@@@*=------------------------------================= + ====================-----------------------------=+#%@@@%%####*******++====++++**++***++==*#%####*****##@@@%@@+=*#***+*##**%@%***##%*==------------------------------================= + =====================-----------------------:---=+*#%@@%%######**##***++++%@@@%#****+=====+***#%####*###@@@%@#*+=+*##%%#*%@%%#*******+=----------------------------=---=============== + ==================-----------------------------=+*#%@@@@%####***##****+*#%#***++++++---=+*++++#@%%%#*#%@@@%%%#*#*+=+*#**##%@%%%#*##**+==------------------------------================ + =====================--------------------------=+#%%@@@@%####********++++*#***##*+==-==**%+*==-+@@%%%%@@@#++###%%%%***#*###%%#%%%####+==------------------------------================ + =================-=---------------------------=+*#%@@@@%####***#######***+***+*#*++=-=*+++===-:-=#@@@@@+=-=++*+++++++#%@@@@@%%%@%###*++=---------------------------------============= + ================-----------------------------==+*%@@@@@%%#####*##%##******++**==**++-==++===----=+@@@@+=--=+*#+++++*++#%@@@%%@@%%%%%#*+=--------------------------------============== + ===============------------------------------=+*#%%@@@@#######*#%%#*****+==+++*#*#*+==-=+++++++++*#@@#+++**#####*##*##%@%*#@%%#%@@%#**+=-------------------------------=============== + ==============------------------------------==+*#%%%%%%%#####**#%%#*++**#*++++++=+=======++*#%%%%@@@@%@@@@@@%%%##**=*#%@@@%%#*#@@@%#*++==-------------------------------============== + ===============----------------------------===+*#%%%%%%%#####**#*****#####**+++===++=++++++++++++++****#########*+#%%*#%#%#%@@@@%##**++==----------------------------------=========== + ===============----------------------------==+**#%%@@@@%#####*+*#%#**#####*+++*+*+++++**++**+++++++*###*#######%##+%*%#+*#%@@@@@@@#*+++==------------------------------------========= + ================---------------------------==+*##%%@@@@%##**#####%#**####**+++*%%*#+**#*###%%%#%#*#####%%%%%%%%@%##+#+#%##@@@@@@@%##**+=------------------------------------========== + =====================---------------------===+*#%%%%@@###########%#+**###***++*#%@*%@%%##%%@%@@@@@@@@@@@@@%@%%%%%#%#*@%@@@@@@@@@%###***=---------------------------------------======= + ==============================-----------====+*##%%%%@@@%##**#####*++**#*#+***+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@%@@@@@@@#@@#%####**==-------------------------------------======== + =====================================-=======+*##%%%%@@@%##****###*++*+##+***+**#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%#####**=--------------------------------------======== + ============================================+**##%%@@@@@%##****###++++***#%%*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@%%%*%%####*+=-------------------------------------=======+ + +++++++++++++++++++=========================+*##%%%@@@@%%#######***++***###*###*##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%*%%####*===------------------=--------------========++ + +++++++++++++++++++++++++++================++*##%%%@@@%%%#####*****++*+**######*#####%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@%@@%%%*%%#*#*+===========================================++ + ++++++++++++++++++++++++++++++++==========+++*##%%%%%%%%%###*******+++*****##%#*#####*****###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%#%%*#%%**++==========================================++ + +++++++++++++++++++++++++++++++++++======+++**#%%%%%%%%##********#*++++*****###***#************###%%%@@@@@@@@%@@@@@@@%@%@@@%%%%%##%*###*++=======================================+++++ + +++++++++++++++++++++++++++++++++++++==+++++*##%%%%%%%##*++++++****++****+**********+++**********#***##%%%#%%%%%%%%@@#%%%%@@@%%%%%###@%#*+++=++++++++++++++++++=++++++++++++++++++++++ + +++++++++++++++++++++++++++++++++++++===+++**#%%%%%%%%#*+++++++*****+++***#***#******+****************#%%#*##%#%##%%@@%%@%%@@%%%%%%@@##**++=++++++++++++++++++++++++++++++++++++++++++ + +*******++++++++++++++++++++++++++++++++++**##%%%%%%%#*++==+++++********+***##*+***************#******#%%#####%###%@@@@@%%%%%%@@%%%%%####+++++++++++++++++++++++++++++++++++++++++++++ + **********++++++++++++++++++++++++++++++++**##%%%%%%##*++==+++++++***###*+++*##**++++*********#####***%@%####%%%%%%%@@@@%%@@@@@@@%%#%%%#*++++++++++++++++++++==+++++++++++++++++++++++ + ************++++++++++++++++++++++++++++++**#%%%%%%%##*++++++++++++****#*+++++*********###########%###%%%%%%%%%%%%@@%%@@@@@@@%%%%%%%%#*+++++++++++++++++++++====++++++++++++++++++++++ + **************++++++++++++++++++++++++++++**#%%@@%%%##*+++++++++++***+++******++++*#####%%%%%%#%#%%%#%%%%%%%%%%%%%%%%%%%%%%%%#%%%%%##***+++++++++++++++++++++++=++++++++++++++++++++++ + **************++++++++++++++++++++*+++++++**#%%@@%%%%#**+++++*********++**###****+*+++**####%%%%%%%%%%%%@@@@@%@@@@%%%%%%%%%%%%%@%%###*******++++++++++++++++++++++++++++++++++++++++++ + ######**********++++++++++++++++****+++++***##%@@@%%%##***+*************++++**#######***++**#%%%%%%@@%%@@@@@@@@@@@%%%%%%%@@@@@%%%%@%%%@@%##**+++++++++++++++++++++++++++++++++++++++** + #########*****+++++++++++++++++***+*+++++***##%%%@@@%%##******######***##**+++++**#####****##%%%%%%@@@@@@@@@@@@@@@%%%@@@@@@@%%%@@@@@@@#**+*++++++++++++++++++++++++++++++++++********* + #########*****++++++++++++++++++*******++***####%%%%%%%#####%%%%%%%##*+*#%%%*+++++++****#%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@###%@@@@%#*++++++++++++++++++++**********+*+**************** + ########******++++++++++++++++++************#####%%%%###%%%%@@@@@@%%#*+++*%%##****++*****###%%%@@@@@@@@@@@@@@@@@@@@@@@@%#**#%@@%#****+++===++++***++++**********************#**#####** + ##########*****+++++++++++++++++************###%%%%%%#%%%%%@@@@@@%%#**++++++*#%@@%###%#####%@@@@@@@@@@@@@@@@@@@@@@@@@%##*#%@@%*+++******+++=++*************#######################%%** + ########********+++++++++++++++*************###%%%%%%%%%%%@@@@@@%##**+++++==+++*#%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@#*++++*******+++++++**********#####################%%%%%%** + %######*************************************####%%%@@@@@@@@%%%%%#***+++***+====++*###%%%@@@@@@@@@@@@@@@@@@@%%#%%@@@@@@@@@#**+++++++*****+++++************##############%%%%%%%%%%%%%** + %%%%####********#######*********************####%%%%@@@@%%%#####*****+*#%#*+===++******###%@@@@@@@@%%%%%%%##***##%%@@@@@##*+===+++******++*************#########%%%%%%%%%%%%%%%%%%%%** + %%%%%%######################****************###%%%%%%%%%##********++++++*+*++++++++****####%%@@@@@@%%%%%%###*****###%%@%##*+++************************######%%%%%%%%%%%%%%%%%%%%%%%%#* + %%%%%%%####################************#########%%%%%%%#*******+++++++++==++++**+++*#####%%%%@@@@@@@@@@@@%%####*****#####**+**+=+++*****+****+**###########%%%%%%%@@@%%%%%%%%%%%%%%%#* + %%%%%%%########%%%%##%#####********####%%%%%#####%%%%%%##*##***********++=+++++++++******###%%@@@@@@@@@@@@%%%##*******##*****++++++++*#**+++*++*##########%%%%%@@@@@@@@@@@@@@@@%%@%%** + %%%%%%%##%%####%%%########*****#######%%%%%%#####%%%%%%###%%%%##########**##%#*+++++++****##%@@@@%%%%%%%%%%%%##*******###**+++=+==++++*#*++++++#%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@%@@%%#* + %%###%##%%%########****###**+*******#############%%%%%%%%%%@@@@@@@@@@%%%##%@@@@%****####%%%%@@@@@%%########%%%##****##%###*++=====+++++***+++**%%%@%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@#* + #####%%%%######*****++*******************########%%%%%#%%%@@@@@@@@@@@@@%#*++*%@@@@@@@@@@@@@@@@@@@%########%%%%%%%%%%%%%%#+=======+++****+++**##%@@@@@@@%%%%@@@@@@@@@@@@@@@@@@@@@@@@@#* + %###%%%%########%%%#**********************#######%%%%##%%%@@@@@@@@@@@@@@%*++++*#%@@@@@@@@@@@@@@@@%########%%@@@@@@@@@@@#========++++********##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#* + %%%%@@@%%###%@@@@@@@@@%%%%#####****####################%%@@@@@@@@@@@@@@@%#*+++++++**##%%%%%%@@@@@@########%%@@@@@@@@@@#+======+++++*++****###%@@@@@%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@#* + @@@@@@@%%###%%@@@@@@@@@@@@@@@@%%%%#%%%%%##%@@%%%%#####%%%@@@@@@@@@@@@@@@@@%##****++++++++++**#%@@%#*######%@@@@@@@@@@#+++=====+++++++++######@@@@@%%%%%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@#* + %@@@@%%##*+++++******##%%@@@@@@@@@@@@@@%##%@@@@@%%%%%%%%@@@@@@@@@@@@@@@@@@%###%%#*+++++++++++************#%@@@@@@@@@%+=+++=====++++***#@%##%@@@@@@%%%%%%@@%%%%%%%%%%%%%%%@@@@@@@@@@@#* + %%%#**++======++++++++****#%@@@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#*+++++++++++++++++++++++******#%@@@@@@@@%+=++++++++=++*###%#####%@@@@@@@@@@@@@@@%%%%@@@@%%%%%@@@@@@@@@@@@#* + #**++==========++++++*****+**%@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##***++==+++++++++++++++++++++*++++*#%@@@@@@@*++++++++++*+****###%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#* + +++++===========++++**++*****#%%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%#******+==+*##*+++++++++**+++++***+++*#%@@@@@#+===+++++*+++*#%@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#* + *+++++========++*++***+++******#####*****#%@@@@@@@@@@@@@@@@@@@@@@@%%#####%%#*+=++++==+++*###***++++**##*+++*#@@@%*+===+++********##%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#* + +++++*++======++******++++*########*******#%%@@@@@@@@@@@@@@@@@@@@@%%%%@@@@@%#+++***++**###******+++*#%%#*++*%@@%+=====++*******#%%%%%%%@@@@@@@@@@@@@@@@@%%#####%%@@@@@@@@@@@@@@@@@@@#* + #*++++**+===++++*####**+++*#%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#**#%@@%@@%%#*++++++++**##%#***#%%#+++=+++++*******##%%#%%%@@@@@@@@@%%%%####*******#%@@@@@@@@@%%%@@@@@@@#* + @%##***++*++++*++*#*####**#%%@@%%%@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@%%%#*+*#%@@@%*++++++++++#%%%##*#%%#**++=+++**#****#%%%%%%%%%@@@@@@@@@%%#####******###%@@@@@@@@@@%%%@@@@@@@#* + %@@@@@@%#%#******###*#%%##%%%@@%@@@@@@@%@@@%#%@@@@@@@@@@@@%%%%@@@@%%%%%%%%%%%%#++++####*++****+*++*#%%%%##%%#+++==++*###%%#%%%@@@@@@#*#%@@@@@%%##%%@@@%%#######%%@@@@@@@@@%%%%@@@@@@#* + @@@@@@@@@@@@@@@%@@@%#%%%@@@%%@@@@@@@@@@%@@@%%@@@@@@@%%@@@@@@@@@%%%%%%%%%%%@@@@%*+=+++++***###*++***##%%%%%%%%#++++*#%##%%%%%%###@@@%#*+*#%%##***#%@@@@@@@@@%%%%%%%#%%%@@@@%%%%%%@@@@#* + @@@@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@%@%@@@@@@@@@@@%@@%%%%%%%%%#+++==++*###***+++##%%@%@%%@@@@@%%%%%%%@@@%#%%%%%@@%#*********++**%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@%* + @@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@%%%%#+==+++++*****++***%%@#@@@@@@@@@@@@@@@@@@%#%%@@@@@@%#***+******##@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@%# + @@@@@%%@@@@@@@@%%@@@@@@@@@%%@@@@@@@@@@@@@%@@@@@@@@@@%@@@@@@%%@@@@@@@@@@@@@@@@%%#+==+++++++*******#%%%@@@@@@@@@@@@@@@@@@@%%%@@@@@@%#**#***####%%@@@@@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@@@%# + + + + + + */ \ No newline at end of file diff --git a/apps/frontend/src/management/components/NeonCard.jsx b/apps/frontend/src/management/components/NeonCard.jsx new file mode 100644 index 0000000..39f9e24 --- /dev/null +++ b/apps/frontend/src/management/components/NeonCard.jsx @@ -0,0 +1,24 @@ +import React from "react"; + + +export default function NeonCard({ + className = "", + as: Tag = "div", + children, + ...rest +}) { + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/management/components/Table.jsx b/apps/frontend/src/management/components/Table.jsx new file mode 100644 index 0000000..c74c0d3 --- /dev/null +++ b/apps/frontend/src/management/components/Table.jsx @@ -0,0 +1,178 @@ +// src/management/components/Table.jsx +import React from "react"; + +/** + * Table (Dark + Nerdy) + * - Dunkles Data-Table mit Hover-Highlight, ANSI-Style Statusfarben und optionaler Toolbar. + * - Fokus auf Lesbarkeit im Dark Mode, Monospace-Option für numerische Spalten. + * + * Props: + * - columns: Array<{ + * key: string; // Feldschlüssel in row + * header: string; // Spaltenüberschrift + * width?: string; // z.B. '120px' | '20%' (optional) + * align?: 'left'|'center'|'right'; // Textausrichtung (default: left) + * monospace?: boolean; // Monospace-Schrift für Werte + * render?: (value, row) => ReactNode; // Custom-Renderer + * }> + * - data: Array // Datensätze + * - loading?: boolean + * - error?: string + * - emptyText?: string // Text bei leerer Tabelle + * - onRowClick?: (row) => void + * - toolbar?: React.ReactNode // Optionaler Toolbar-Content (rechts oben) + * - dense?: boolean // kompaktere Zeilen + * + * Beispiele für Status-Farben (ANSI-Style): + * + * + * + */ + +export function StatusBadge({ status }) { + const s = String(status || "").toUpperCase(); + const cls = + s === "OK" + ? "text-green-400" + : s === "WARN" + ? "text-orange-400" + : s === "FAIL" + ? "text-red-400" + : "text-white/70"; + return {s || "—"}; +} + +export default function Table({ + columns = [], + data = [], + loading = false, + error = "", + emptyText = "Keine Daten", + onRowClick, + toolbar, + dense = false, +}) { + const rowPad = dense ? "py-1.5" : "py-2.5"; + const cellPad = dense ? "py-1.5 pr-4" : "py-2.5 pr-4"; + + return ( +
+ {/* Header mit optionaler Toolbar */} +
{toolbar}
+ +
+ + + + {columns.map((col) => ( + + ))} + + + + + {/* Loading */} + {loading && ( + + + + )} + + {/* Error */} + {!loading && error && ( + + + + )} + + {/* Empty */} + {!loading && !error && (!data || data.length === 0) && ( + + + + )} + + {/* Rows */} + {!loading && + !error && + Array.isArray(data) && + data.map((row, idx) => { + const clickable = typeof onRowClick === "function"; + return ( + clickable && onRowClick(row)} + > + {columns.map((col) => { + const value = row[col.key]; + const content = col.render + ? col.render(value, row) + : value ?? "—"; + return ( + + ); + })} + + ); + })} + +
+ {col.header} +
+ Lädt… +
+ {error} +
+ {emptyText} +
+ {content} +
+
+
+ ); +} + diff --git a/apps/frontend/src/management/pages/AdminTransactionsPage.jsx b/apps/frontend/src/management/pages/AdminTransactionsPage.jsx new file mode 100644 index 0000000..cac6dc6 --- /dev/null +++ b/apps/frontend/src/management/pages/AdminTransactionsPage.jsx @@ -0,0 +1,447 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api"; + +const euro = (c) => (c ?? 0) / 100; +const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" }); +const SHOW_CREATE = false; + +const STATUS_LABELS_DE = { + pending: "ausstehend", + confirmed: "bestätigt", + rejected: "abgelehnt", +}; +const statusLabel = (s) => STATUS_LABELS_DE[s] ?? String(s ?? ""); + +function Pill({ status }) { + const map = { + pending: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30", + confirmed: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", + rejected: "bg-rose-500/20 text-rose-300 border-rose-500/30", + }; + return ( + + {statusLabel(status)} + + ); +} + + + +/** Kleiner Avatar (Initialen) */ +function Initials({ label }) { + const txt = String(label || "") + .split(/\s+/).filter(Boolean).slice(0,2).map(s=>s[0]?.toUpperCase()).join("") || "•"; + return ( +
+ {txt} +
+ ); +} + +/** Suchbare Combobox ohne externe Lib */ +function UserSelect({ users, value, onChange, placeholder = "— auswählen —" }) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const btnRef = useRef(null); + const popRef = useRef(null); + const [hi, setHi] = useState(0); + + const options = useMemo(() => { + const needle = q.trim().toLowerCase(); + const list = users || []; + if (!needle) return list; + return list.filter(u => { + const s = `${u.alias || ""} ${u.name || ""} ${u.email || ""}`.toLowerCase(); + return s.includes(needle); + }); + }, [users, q]); + + const selected = useMemo(() => (users || []).find(u => u.id === value), [users, value]); + + useEffect(() => { + function onDoc(e) { + if (!open) return; + if ( + popRef.current && popRef.current.contains(e.target) + ) return; + if ( + btnRef.current && btnRef.current.contains(e.target) + ) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + function onKey(e) { + if (!open) return; + if (e.key === "ArrowDown") { + e.preventDefault(); setHi((i) => Math.min(i + 1, Math.max(0, options.length - 1))); + } else if (e.key === "ArrowUp") { + e.preventDefault(); setHi((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const pick = options[hi]; + if (pick) { onChange(pick.id); setOpen(false); } + } else if (e.key === "Escape") { + e.preventDefault(); setOpen(false); + } + } + + return ( +
+ + + {open && ( +
+ { setQ(e.target.value); setHi(0); }} + placeholder="Suchen…" + className="w-full mb-2 bg-black/40 text-white rounded-lg border border-white/20 px-3 py-2" + /> +
    + {options.map((u, idx) => ( +
  • setHi(idx)} + onMouseDown={(e) => { e.preventDefault(); onChange(u.id); setOpen(false); }} + className={`px-2 py-2 rounded-lg cursor-pointer flex items-center gap-2 ${ + idx === hi ? "bg-white/10" : "hover:bg-white/5" + }`} + > + +
    +
    {u.alias || u.name || u.email}
    +
    {u.email || ""}
    +
    +
  • + ))} + {!options.length &&
  • Keine Treffer
  • } +
+
+ )} +
+ ); +} + + + + +function StatusSelect({ value, onChange }) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + const OPTIONS = [ + { value: "", label: "alle" }, + { value: "pending", label: "ausstehend" }, + { value: "confirmed", label: "bestätigt" }, + { value: "rejected", label: "abgelehnt" }, + ]; + + useEffect(() => { + function onDoc(e) { + if (!open) return; + if (popRef.current?.contains(e.target)) return; + if (btnRef.current?.contains(e.target)) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const current = OPTIONS.find(o => o.value === value) || OPTIONS[0]; + + return ( +
+ + {open && ( +
+ {OPTIONS.map(opt => ( + + ))} +
+ )} +
+ ); +} + + +export default function AdminTransactionsPage() { + // Daten + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + // Nutzerliste (max 200) + const [users, setUsers] = useState([]); + + // Filter + const STATUS_OPTS = [ + { value: "", label: "Alle" }, + { value: "pending", label: "pending" }, + { value: "confirmed", label: "confirmed" }, + { value: "rejected", label: "rejected" }, +]; + +const [filterUserId, setFilterUserId] = useState(null); // Nutzer-Filter (Combobox) +const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirmed", "rejected" + + + + // Formular Neu + const [form, setForm] = useState({ user_id: null, amount_eur: "", note: "" }); + const [submitting, setSubmitting] = useState(false); + + const usersById = useMemo(() => { + const m = new Map(); + users.forEach(u => m.set(u.id, u)); + return m; + }, [users]); + + async function loadAll() { + setLoading(true); + setErr(null); + try { + const [tx, us] = await Promise.all([ + listTopupsAdmin({ + limit: 200, + offset: 0, + status_filter: statusFilter || undefined, + user_id: filterUserId || undefined, + }), + getUsersLite({ limit: 200, offset: 0 }), + ]); + + setRows(Array.isArray(tx) ? tx : (tx.items || [])); + setUsers(Array.isArray(us) ? us : (us.items || [])); + } catch (e) { + setErr(e?.message || "Fehler beim Laden"); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [statusFilter, filterUserId]); + + async function onCreate(e) { + e.preventDefault(); + const uid = Number(form.user_id); + const eur = String(form.amount_eur ?? "").replace(",", "."); + const cents = Math.round(Number(eur) * 100); + if (!uid || !Number.isFinite(cents) || cents <= 0) return; + + setSubmitting(true); + try { + const created = await createTopupAdmin({ user_id: uid, amount_cents: cents, note: form.note || "" }); + setRows(prev => [created, ...prev]); + setForm({ user_id: null, amount_eur: "", note: "" }); + } catch (e) { + alert(e?.message || "Top-up konnte nicht erstellt werden."); + } finally { + setSubmitting(false); + } + } + + async function onApprove(id) { + try { + await patchTopupStatus(id, "confirmed"); + setRows(prev => prev.map(r => (r.id === id ? { ...r, status: "confirmed" } : r))); + } catch (e) { + alert(e?.message || "Bestätigen fehlgeschlagen."); + } + } + async function onReject(id) { + try { + await patchTopupStatus(id, "rejected"); + setRows(prev => prev.map(r => (r.id === id ? { ...r, status: "rejected" } : r))); + } catch (e) { + alert(e?.message || "Ablehnen fehlgeschlagen."); + } + } + + return ( +
+ {/* Formular Neu – ausgeblendet */} + {SHOW_CREATE && ( +
+

Neue Aufladung / Admin-Transaktion

+
+ + + + + +
+ +
+ +
+
+ )} + {/* Tabelle */} +
+
+

Alle Transaktionen

+ +
+ {/* Schöner Status-Filter */} + + + {/* Nutzer-Filter (schöne Combobox), rechts vom Status-Filter */} +
+ +
+ + +
+
+ + + {loading ? ( +
Lade…
+ ) : err ? ( +
{String(err)}
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map(r => { + const u = usersById.get(r.user_id); + const canAct = String(r.status) === "pending"; + const ts = r.created_at ? new Date(r.created_at) : null; + return ( + + + + + + + + + + ); + })} + {!rows.length && ( + + + + )} + +
IDZeitNutzerBetrag (€)StatusNotizAktionen
{r.id}{ts ? fmtDT.format(ts) : "—"}{u?.name || u?.alias || u?.email || r.user_id}{fmt.format(euro(r.amount_cents))}{r.note || "—"} +
+ + +
+
Keine Einträge.
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/management/pages/BookingsPage.jsx b/apps/frontend/src/management/pages/BookingsPage.jsx new file mode 100644 index 0000000..5ade835 --- /dev/null +++ b/apps/frontend/src/management/pages/BookingsPage.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { getMyBookings, getProducts } from "../../api"; + +/** ---------- Zeit-Utils: Postgres-String -> RFC3339 ---------- */ +function normalizePgTimestamp(s) { + if (!s || typeof s !== "string") return s; + let t = s.replace(" ", "T"); + // Mikrosekunden (6 Stellen) -> Millisekunden (3 Stellen) + t = t.replace(/(\.\d{3})\d+([Z+-])/, "$1$2"); + // Offset +HH / -HH -> +HH:00 / -HH:00 + t = t.replace(/([+-]\d{2})$/, "$1:00"); + return t; +} +function parseTs(s) { + const iso = normalizePgTimestamp(s); + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} +function formatTsBerlin(s) { + const d = parseTs(s); + if (!d) return "—"; + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "medium", + timeZone: "Europe/Berlin", + }).format(d); +} +/** ------------------------------------------------------------- */ + +export default function BookingsPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [productMap, setProductMap] = useState(() => new Map()); // id -> name + + useEffect(() => { + let alive = true; + (async () => { + try { + setLoading(true); + // Buchungen + Produkte parallel laden (Products nur für Name-Lookup) + const [bData, pData] = await Promise.all([ + getMyBookings(), + getProducts().catch(() => []), + ]); + if (!alive) return; + + const items = Array.isArray(bData) ? bData : (bData.items || []); + items.sort((a, b) => { + const ta = parseTs(a.created_at ?? a.timestamp)?.getTime() ?? 0; + const tb = parseTs(b.created_at ?? b.timestamp)?.getTime() ?? 0; + return tb - ta; + }); + setRows(items); + + const map = new Map(); + if (Array.isArray(pData)) { + for (const p of pData) { + if (p && typeof p.id !== "undefined") map.set(p.id, p.name || String(p.id)); + } + } + setProductMap(map); + } catch (e) { + if (alive) setErr(e); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + }, []); + + if (loading) return
Lade Buchungen…
; + if (err) return
Keine Buchungen verfügbar (Endpoint fehlt oder keine Daten).
; + + return ( +
+

Meine Buchungen

+
+ + + + + + + + + + + {rows.map((r) => { + const name = + r.product?.name ?? + r.product_name ?? + (typeof r.product_id !== "undefined" + ? productMap.get(r.product_id) ?? String(r.product_id) + : "—"); + + return ( + + + + + + + ); + })} + +
ZeitProduktMengeGesamt (€)
+ {formatTsBerlin(r.created_at ?? r.timestamp)} + {name} + {r.amount ?? r.quantity ?? 1} + + {r.total_cents != null ? (r.total_cents / 100).toFixed(2) : "—"} +
+
+
+ ); +} diff --git a/apps/frontend/src/management/pages/DashboardPage.jsx b/apps/frontend/src/management/pages/DashboardPage.jsx new file mode 100644 index 0000000..cb6e1eb --- /dev/null +++ b/apps/frontend/src/management/pages/DashboardPage.jsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from "react"; +import { getCapabilities, getStatsSummary } from "../../api"; +import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi"; + +function Panel({ title, icon: Icon, children, accent = "cyan" }) { + const accentClasses = + accent === "red" + ? "border-red-400/25 shadow-[0_0_0_1px_rgba(248,113,113,0.06)]" + : accent === "amber" + ? "border-amber-400/25 shadow-[0_0_0_1px_rgba(251,191,36,0.06)]" + : accent === "emerald" + ? "border-emerald-400/25 shadow-[0_0_0_1px_rgba(16,185,129,0.06)]" + : "border-cyan-400/25 shadow-[0_0_0_1px_rgba(34,211,238,0.06)]"; + + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} + +export default function DashboardPage() { + const [caps, setCaps] = useState([]); + const [summary, setSummary] = useState(null); + const [statsDenied, setStatsDenied] = useState(false); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let alive = true; + (async () => { + try { + const { capabilities } = await getCapabilities(); + if (!alive) return; + const c = Array.isArray(capabilities) ? capabilities : []; + setCaps(c); + + if (c.includes("stats-advanced")) { + try { + const s = await getStatsSummary(); + if (!alive) return; + setSummary(s || null); + setStatsDenied(false); + } catch (e) { + // 403 oder andere Fehler -> degradiert anzeigen + if (e?.response?.status === 403 || e?.status === 403) { + setStatsDenied(true); + setSummary(null); + } else { + // kein harter Crash – wir degradieren + setSummary(null); + } + } + } + } catch (e) { + if (alive) setErr(e); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, []); + + // --- ab hier keine neuen Hooks mehr --- + + // Ableitungen robust ohne useMemo/useCallback + const balanceCents = summary?.my_balance_cents; + const balanceText = + typeof balanceCents === "number" ? (balanceCents / 100).toFixed(2) + " €" : "—"; + const balanceAccent = + typeof balanceCents === "number" + ? balanceCents < 0 + ? "red" + : balanceCents < 500 + ? "amber" + : "emerald" + : "cyan"; + + const stock = + summary?.stock_overview && typeof summary.stock_overview === "object" + ? summary.stock_overview + : null; + + const sys = { + users: summary?.num_users ?? "—", + products: summary?.num_products ?? "—", + bookings: summary?.num_bookings ?? "—", + revenue: + typeof summary?.total_revenue_cents === "number" + ? (summary.total_revenue_cents / 100).toFixed(2) + " €" + : "—", + }; + + if (loading) return
Lade Dashboard…
; + if (err) return
Fehler: {String(err.message || err)}
; + + return ( +
+ {/* Kontostand */} + +
+ {balanceText} + {balanceText !== "—" && ( + + )} +
+ {balanceText === "—" && ( +
+ Kontostand verbinden + + /stats/summary). +
+ )} +
+ + {/* Aufladen */} + +
+ Guthaben erhöhen. Paypal link verbinden diesdas... +
+ +
+ + {/* Getränkelage */} + + {stock ? ( +
+ {Object.entries(stock).map(([k, v]) => { + const pct = Math.max(0, Math.min(1, Number(v) || 0)); + const barClass = + pct < 0.25 ? "bg-red-400/60" : pct < 0.5 ? "bg-amber-400/60" : "bg-emerald-400/60"; + return ( +
+
{k}
+
+
+
+
+ {Math.round(pct * 100)}% +
+
+ ); + })} +
+ ) : ( +
+ Gut gefüllt nächste Lieferungen am: 30.01.26 + +
+ )} + + + {/* Systemstatus */} + +
+
Users: {sys.users}
+
Products: {sys.products}
+
Bookings: {sys.bookings}
+
Revenue: {sys.revenue}
+
+ + {!caps.includes("stats-advanced") && ( +
+ Hinweis: Erweiterte Statistiken sind nicht freigeschaltet (stats-advanced). +
+ )} + {caps.includes("stats-advanced") && statsDenied && ( +
+ Zugriff verweigert (403). Prüfe Auth/Rolle/CSRF des Endpoints /stats/summary. +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/management/pages/DeliveriesPage.jsx b/apps/frontend/src/management/pages/DeliveriesPage.jsx new file mode 100644 index 0000000..4b7395c --- /dev/null +++ b/apps/frontend/src/management/pages/DeliveriesPage.jsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from "react"; +import { getDeliveries } from "../../api"; + +export default function DeliveriesPage() { + const [rows, setRows] = useState([]); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let alive = true; + (async () => { + try { + const data = await getDeliveries(); + if (!alive) return; + setRows(Array.isArray(data) ? data : []); + } catch (e) { + if (alive) setErr(e); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + }, []); + + if (loading) return
Lade Lieferungen…
; + if (err) return
Fehler: {String(err.message || err)}
; + + return ( +
+

Lieferungen

+
+ + + + + + + + + + + + {rows.map((d) => ( + + + + + + + + ))} + +
IDProduktMengePreis (€)Notiz
{d.id}{d.product?.name ?? d.product_id}{d.amount}{((d.price_cents ?? 0)/100).toFixed(2)}{d.note ?? "–"}
+
+
+ ); +} diff --git a/apps/frontend/src/management/pages/LogsPage.jsx b/apps/frontend/src/management/pages/LogsPage.jsx new file mode 100644 index 0000000..681add6 --- /dev/null +++ b/apps/frontend/src/management/pages/LogsPage.jsx @@ -0,0 +1,364 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { getAuditLogs, getUsersLite } from "../../api"; + +/* ----------------------------- kleine Utils ----------------------------- */ + +function euroDelta(newC, oldC) { + if (newC == null || oldC == null) return null; + const d = (newC - oldC) / 100; + return (d >= 0 ? "+" : "") + d.toFixed(2) + " €"; +} +function fmtDT(v) { + if (!v) return "—"; + const d = new Date(v); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(); +} +function classNames(...xs) { return xs.filter(Boolean).join(" "); } + +/* -------------------------- NiceDropdown (inline) ------------------------ */ + +function NiceDropdown({ + value, onChange, options, placeholder = "Auswählen…", + className = "", buttonClassName = "", menuClassName = "", minWidth = 160, +}) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + const current = useMemo( + () => options?.find((o) => String(o.value) === String(value)) ?? null, + [options, value] + ); + + useEffect(() => { + function onDoc(e) { + if (!open) return; + if (popRef.current?.contains(e.target)) return; + if (btnRef.current?.contains(e.target)) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + return ( +
+ + + {open && ( +
+ {options.map(opt => { + const active = String(opt.value) === String(value); + return ( + + ); + })} +
+ )} +
+ ); +} + +/* ------------------------ UserPicker (Such-Combobox) --------------------- */ + +function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + useEffect(() => { + let alive = true; + if (!open) return; + (async () => { + setLoading(true); + try { + const res = await getUsersLite({ limit: 20, offset: 0, q }); + const arr = Array.isArray(res) ? res : (res.items || []); + if (alive) setItems(arr); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + }, [open, q]); + + useEffect(() => { + function onDoc(e) { + if (!open) return; + if (popRef.current?.contains(e.target)) return; + if (btnRef.current?.contains(e.target)) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const selected = items.find(u => String(u.id) === String(value)); + + return ( +
+ + + {open && ( +
+ setQ(e.target.value)} + /> +
+ {loading ? ( +
Laden…
+ ) : ( + <> + + {items.map(u => ( + + ))} + + )} +
+
+ )} +
+ ); +} + +/* --------------------------------- Page ---------------------------------- */ + +export default function LogsPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [hasMore, setHasMore] = useState(true); + + // Filter-Params + const [userId, setUserId] = useState(""); + const [action, setAction] = useState(""); + const [range, setRange] = useState("7d"); // 24h, 7d, 30d, all + const [query, setQuery] = useState(""); + + const [offset, setOffset] = useState(0); + const LIMIT = 100; + + function computeRange(r) { + if (r === "all") return {}; + const now = new Date(); + let from = new Date(now); + if (r === "24h") from.setHours(now.getHours() - 24); + else if (r === "7d") from.setDate(now.getDate() - 7); + else if (r === "30d") from.setDate(now.getDate() - 30); + return { date_from: from.toISOString(), date_to: now.toISOString() }; + } + + async function load(reset = false) { + setLoading(true); + setErr(null); + try { + const pageOffset = reset ? 0 : offset; + const { date_from, date_to } = computeRange(range); + const data = await getAuditLogs({ + limit: LIMIT, + offset: pageOffset, + user_id: userId || undefined, + action: action || undefined, + q: query || undefined, + date_from, + date_to, + }); + const arr = Array.isArray(data) ? data : (data.items || []); + setRows((prev) => (reset ? arr : [...prev, ...arr])); + setHasMore(arr.length === LIMIT); + setOffset(pageOffset + arr.length); + } catch (e) { + setErr(e); + } finally { + setLoading(false); + } + } + + // Initial + bei Filterwechsel neu laden + useEffect(() => { + load(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, action, range]); + + // Clientseitige Fallback-Suche (falls Backend "q" ignoriert) + const visible = useMemo(() => { + if (!query) return rows; + const q = query.toLowerCase(); + return rows.filter(r => + (r.user?.name || "").toLowerCase().includes(q) || + String(r.user_id ?? "").includes(q) || + String(r.action ?? "").toLowerCase().includes(q) || + String(r.info ?? "").toLowerCase().includes(q) + ); + }, [rows, query]); + + return ( +
+

Audit-Logs

+ + {/* Toolbar */} +
+ + + + + + + setQuery(e.target.value)} + /> + + +
+ + {/* Tabelle */} +
+ + + + + + + + + + + + {visible.map((r) => ( + + + + + + + + ))} + {loading && ( + + + + )} + {!loading && visible.length === 0 && ( + + + + )} + +
ZeitUserAktionInfoΔ Balance
{fmtDT(r.timestamp)}{r.user?.name ?? r.user?.alias ?? r.user_id}{String(r.action ?? "—")}{r.info ?? "—"}{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "—"}
Laden…
Keine Einträge.
+
+ + {/* Pagination */} +
+ +
+ {rows.length} geladen{hasMore ? " (Mehr verfügbar)" : ""} +
+
+
+ ); +} diff --git a/apps/frontend/src/management/pages/NotFoundPage.jsx b/apps/frontend/src/management/pages/NotFoundPage.jsx new file mode 100644 index 0000000..a7c5765 --- /dev/null +++ b/apps/frontend/src/management/pages/NotFoundPage.jsx @@ -0,0 +1,10 @@ +import React from "react"; + +export default function NotFoundPage() { + return ( +
+

Nicht gefunden

+

Die angeforderte Seite existiert nicht.

+
+ ); +} diff --git a/apps/frontend/src/management/pages/ProductsPage.jsx b/apps/frontend/src/management/pages/ProductsPage.jsx new file mode 100644 index 0000000..7fdeb9e --- /dev/null +++ b/apps/frontend/src/management/pages/ProductsPage.jsx @@ -0,0 +1,782 @@ +import React, { useEffect, useMemo, useRef, useState, useDeferredValue, startTransition } from "react"; +import { + getProducts, + getCategories as apiGetCategories, + renameCategory as apiRenameCategory, + deleteCategory as apiDeleteCategory, + updateProduct, +} from "../../api"; +import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi"; + +/* ---------- Helpers ---------- */ +const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2); +const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const uniq = (arr) => Array.from(new Set(arr)); +const displayVal = (v) => (v === 0 ? 0 : v ?? "–"); // 0 nicht verschlucken + +export default function ProductsPage() { + const [rows, setRows] = useState([]); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(true); + + // Toolbar-States + const [search, setSearch] = useState(""); + const deferredSearch = useDeferredValue(search); + const [category, setCategory] = useState("ALLE"); + + // Sortierung + const [sortBy, setSortBy] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + + // Modals + const [viewItem, setViewItem] = useState(null); + const [editItem, setEditItem] = useState(null); + const [catMgrOpen, setCatMgrOpen] = useState(false); + const [reassignInfo, setReassignInfo] = useState(null); // {oldCat} + + // Kategorien-State (Serverquelle + „ALLE“) + const [categories, setCategories] = useState(["ALLE"]); + + useEffect(() => { + refreshProductsAndCategories(); + }, []); + + async function refreshProductsAndCategories() { + try { + const [data, cats] = await Promise.all([getProducts(), apiGetCategories()]); + setRows(Array.isArray(data) ? data : []); + setCategories(["ALLE", ...cats]); + } catch (e) { + setErr(e); + } finally { + setLoading(false); + } + } + + // Filter + Suche +const filtered = useMemo(() => { + const q = deferredSearch.trim().toLowerCase(); + return rows.filter((r) => { + const catOk = category === "ALLE" || (r.category ?? "") === category; + if (!catOk) return false; + if (!q) return true; + const hay = [r.name, r.category, r.supplier_number, String(r.id)] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return hay.includes(q); + }); + }, [rows, category, deferredSearch]); + + // Spalten + const columns = useMemo(() => { + const base = [ + { id: "id", label: "ID", get: (p) => p.id, mono: true, type: "num" }, + { id: "name", label: "Name", get: (p) => p.name ?? "" }, + { + id: "price_cents", + label: "Preis (€)", + get: (p) => Number(p.price_cents ?? 0), + render: (p) => euro(p.price_cents), + mono: true, + type: "num", + }, + { id: "category", label: "Kategorie", get: (p) => p.category ?? "" }, + { id: "stock", label: "Bestand", get: (p) => (p.stock ?? p.stock === 0 ? p.stock : null), type: "num" }, + ]; + const hasVolume = filtered.some((r) => r.volume_ml !== undefined && r.volume_ml !== null); + if (hasVolume) { + base.splice(4, 0, { + id: "volume_ml", + label: "Volumen (ml)", + get: (p) => (p.volume_ml ?? p.volume_ml === 0 ? p.volume_ml : null), + type: "num", + }); + } + return base; + }, [filtered]); + + const sorted = useMemo(() => { + const col = columns.find((c) => c.id === sortBy) ?? columns[0]; + const arr = filtered.slice(); + arr.sort((a, b) => { + const av = col.get(a); + const bv = col.get(b); + let res; + if (col.type === "num") { + const ax = av == null ? -Infinity : Number(av); + const bx = bv == null ? -Infinity : Number(bv); + res = ax - bx; + } else { + res = cmp(String(av ?? ""), String(bv ?? "")); + } + return sortDir === "asc" ? res : -res; + }); + return arr; + }, [filtered, columns, sortBy, sortDir]); + + const toggleSort = (id) => { + if (id === sortBy) { + startTransition(() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))); + } else { + startTransition(() => { setSortBy(id); setSortDir("asc"); }); + } + }; + const sortMark = (id) => (id !== sortBy ? "∙" : sortDir === "asc" ? "▲" : "▼"); + + // Kategorie-Operationen + const addCategory = (name) => { + const n = (name || "").trim(); + if (!n || n === "ALLE") return; + setCategories((prev) => (prev.includes(n) ? prev : [...prev, n].sort((a, b) => a.localeCompare(b, "de")))); + }; + + async function onRenameCategory(oldName, newName) { + const nn = (newName || "").trim(); + if (!nn || nn === "ALLE" || oldName === "ALLE") return; + await apiRenameCategory(oldName, nn); + await refreshProductsAndCategories(); + if (category === oldName) setCategory(nn); + } + + function requestDeleteCategory(cat) { + if (cat === "ALLE") return; + const affected = rows.filter((r) => r.category === cat).length; + if (affected === 0) { + setCategories((prev) => prev.filter((x) => x !== cat)); + if (category === cat) setCategory("ALLE"); + } else { + setReassignInfo({ oldCat: cat }); + setCatMgrOpen(false); + } + } + + async function performReassignAndDelete(oldCat, targetCat) { + const tgt = (targetCat || "").trim(); + if (!tgt || tgt === "ALLE") return; + await apiDeleteCategory(oldCat, tgt); + await refreshProductsAndCategories(); + if (category === oldCat) setCategory(tgt); + setReassignInfo(null); + } + + // Persistentes Speichern aus dem Edit-Dialog + async function saveEditedProduct(updated) { + const payload = { + name: updated.name, + category: updated.category, + volume_ml: updated.volume_ml, + price_cents: updated.price_cents, + supplier_number: updated.supplier_number ?? null, + stock: updated.stock, + is_active: updated.is_active, + }; + await updateProduct(updated.id, payload); + await refreshProductsAndCategories(); + setEditItem(null); + } + + if (loading) return
Lade Produkte…
; + if (err) return
Fehler: {String(err.message || err)}
; + + return ( +
+

Produkte

+ + {/* Toolbar */} +
+ {/* Kategorien */} +
+ {categories.map((c) => ( + + ))} + +
+ + {/* Suche */} +
+
+ + setSearch(e.target.value)} + placeholder="Suchen (Name, Kategorie, ID, Lieferantennr.)" + className="pl-9 pr-10 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/40 w-80 focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> +
+ {search && ( + + )} +
+
+ + {/* Tabelle */} +
+ + + + {columns.map((c) => ( + + ))} + + + + + {sorted.map((p) => ( + + {columns.map((c) => ( + + ))} + + + ))} + {sorted.length === 0 && ( + + + + )} + +
+ + Aktionen
+ {c.render ? c.render(p) : displayVal(c.get(p))} + +
+ + +
+
+ Keine Produkte gefunden. +
+
+ + {/* Modals */} + {viewItem && ( + setViewItem(null)} title="Produkt ansehen"> + + + )} + + {editItem && ( + setEditItem(null)} title="Produkt bearbeiten"> + c !== "ALLE")} + onCancel={() => setEditItem(null)} + onSave={saveEditedProduct} + /> + + )} + + {catMgrOpen && ( + setCatMgrOpen(false)} title="Kategorien verwalten"> + c !== "ALLE")} + onAdd={addCategory} + onRename={onRenameCategory} + onDelete={requestDeleteCategory} + /> + + )} + + {reassignInfo && ( + setReassignInfo(null)} title="Kategorie neu zuweisen"> + c !== "ALLE" && c !== reassignInfo.oldCat)} + onCancel={() => setReassignInfo(null)} + onConfirm={(targetCat) => performReassignAndDelete(reassignInfo.oldCat, targetCat)} + /> + + )} +
+ ); +} + +/* ---------- Sub-Komponenten ---------- */ + +function Modal({ title, children, onClose }) { + return ( +
+
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +function DetailView({ item }) { + return ( +
+ {item.id}} /> + + + + {"volume_ml" in item && } + + + +
+ ); +} + +function InfoRow({ label, value }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/* ===== Edit-Form mit Animated Switch + Category Dropdown ===== */ + +function EditForm({ item, onSave, onCancel, categories }) { + const [form, setForm] = useState({ + id: item.id, + name: item.name ?? "", + price_cents: Number(item.price_cents ?? 0), + category: item.category ?? "", + stock: item.stock ?? 0, + volume_ml: item.volume_ml ?? "", + is_active: item.is_active ?? true, + supplier_number: item.supplier_number ?? "", + }); + const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); + + return ( +
{ + e.preventDefault(); + onSave({ + ...item, + ...form, + price_cents: Number(form.price_cents) || 0, + stock: form.stock === "" ? null : Number(form.stock), + volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml), + }); + }} + > +
+ + set("name", e.target.value)} + required + /> + + + + set("category", v)} + options={categories} + /> + +
+ +
+ + set("price_cents", e.target.value)} + min={0} + /> + + + set("volume_ml", e.target.value)} + min={0} + /> + + + set("stock", e.target.value)} + min={0} + /> + +
+ +
+ + set("supplier_number", e.target.value)} + /> + + + + set("is_active", v)} + labelOn="aktiv" + labelOff="inaktiv" + /> + +
+ +
+ +
+ {euro(form.price_cents)} € +
+
+
+
+ +
+ + +
+ + ); +} + +function Field({ label, children }) { + return ( + + ); +} + +/* ===== Schönes Kategorie-Dropdown mit Filter ===== */ + +function CategorySelect({ value, onChange, options, placeholder = "Kategorie wählen" }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Für stabile Anzeige ohne Duplikate/Nulls + const cleanedOpts = useMemo( + () => Array.from(new Set((options || []).filter(Boolean))), + [options] + ); + + // Klick außerhalb schließt Dropdown + useEffect(() => { + if (!open) return; + const onDocClick = (e) => { + if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); + }; + const onKey = (e) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + + {open && ( +
+
    + {cleanedOpts.length === 0 && ( +
  • Keine Kategorien.
  • + )} + {cleanedOpts.map((opt) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} + + +/* ===== Animated Switch (ohne externe Lib) ===== */ + +function Switch({ checked, onChange, labelOn = "On", labelOff = "Off" }) { + return ( + + ); +} + +/* ---- Kategorien-Manager ---- */ + +function CategoryManager({ categories, onAdd, onRename, onDelete }) { + const [newName, setNewName] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameVal, setRenameVal] = useState(""); + + return ( +
+
+ setNewName(e.target.value)} + /> + +
+ +
    + {categories.length === 0 && ( +
  • Keine eigenen Kategorien.
  • + )} + {categories.map((c) => ( +
  • + {renaming === c ? ( +
    + setRenameVal(e.target.value)} + autoFocus + /> + + +
    + ) : ( + <> + {c} +
    + + +
    + + )} +
  • + ))} +
+

+ Hinweis: Das Löschen einer genutzten Kategorie erfordert die Neuzuordnung aller zugehörigen Produkte. +

+
+ ); +} + +/* ---- Reassign-Dialog ---- */ + +function ReassignCategoryForm({ oldCat, categories, onCancel, onConfirm }) { + const [target, setTarget] = useState(categories[0] ?? ""); + const [custom, setCustom] = useState(""); + + const effectiveTarget = custom.trim() || target; + + return ( +
{ + e.preventDefault(); + if (!effectiveTarget) return; + onConfirm(effectiveTarget); + }} + > +

+ Die Kategorie {oldCat} wird aktuell verwendet. Bitte wähle eine{" "} + neue Kategorie für alle betroffenen Produkte oder gib eine neue ein. +

+ +
+ + + + + setCustom(e.target.value)} + /> + +
+ +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/management/pages/SettingsPage.jsx b/apps/frontend/src/management/pages/SettingsPage.jsx new file mode 100644 index 0000000..166124c --- /dev/null +++ b/apps/frontend/src/management/pages/SettingsPage.jsx @@ -0,0 +1,388 @@ +import React, { useEffect, useState } from "react"; +import { + getCurrentUser, + updateOwnProfile, + requestNewPin, + uploadAvatar, + API_BASE, +} from "../../api"; +import { + FiSave, + FiKey, + FiAtSign, + FiTag, + FiUpload, + FiImage, + FiAlertTriangle, + FiDroplet, + FiRefreshCw, +} from "react-icons/fi"; + +/* ---------- Theme: Order-Background ---------- */ +const DEFAULT_BG1 = "#0a6bff"; // sanftes Blau +const DEFAULT_BG2 = "#00e0aa"; // Türkisgrün + +const toBool = (v) => v === true || v === 1 || v === "1" || v === "true"; + + +function readOrderColors() { + const c1 = localStorage.getItem("order_bg1") || DEFAULT_BG1; + const c2 = localStorage.getItem("order_bg2") || DEFAULT_BG2; + return { c1, c2 }; +} +function applyOrderColors(c1, c2) { + document.documentElement.style.setProperty("--order-bg1", c1); + document.documentElement.style.setProperty("--order-bg2", c2); + window.dispatchEvent(new CustomEvent("order-bg-change", { detail: { bg1: c1, bg2: c2 } })); +} + +/* ---------- Komponente ---------- */ +export default function SettingsPage() { + const [form, setForm] = useState({ alias: "", paypal: "", visible: true }); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPassword2, setNewPassword2] = useState(""); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(null); + + // Avatar + const [avatarUrl, setAvatarUrl] = useState(null); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [uploading, setUploading] = useState(false); + + // Theme-Farben + const initial = readOrderColors(); + const [bg1, setBg1] = useState(initial.c1); + const [bg2, setBg2] = useState(initial.c2); + + useEffect(() => { + (async () => { + const user = await getCurrentUser(); + setForm({ + alias: user.alias || "", + paypal: user.paypal_email || "", + visible: toBool(user.public_stats ?? user.visible_in_stats), + }); + // Avatar-URL absolutieren, falls relativ + const raw = user.avatar_url || null; + setAvatarUrl(raw ? (raw.startsWith("http") ? raw : `${API_BASE}${raw}`) : null); + + applyOrderColors(bg1, bg2); + })(); + }, []); + + function setField(key, val) { + setForm((f) => ({ ...f, [key]: val })); + } + + async function save() { + setLoading(true); + setMsg(null); + + // Passwortvalidierung + if (newPassword || newPassword2 || currentPassword) { + if (!currentPassword) return stop("Aktuelles Passwort ist erforderlich."); + if (newPassword.length < 8) return stop("Neues Passwort zu kurz (min. 8 Zeichen)."); + if (newPassword !== newPassword2) return stop("Neue Passwörter stimmen nicht überein."); + } + + function stop(m) { + setMsg(m); + setLoading(false); + return; + } + + const vis = toBool(form.visible); + const patch = { + alias: form.alias, + paypal_email: form.paypal, + public_stats: vis, + visible_in_stats: vis, + }; + + try { + await updateOwnProfile(patch); + const fresh = await getCurrentUser(); + setForm(f => ({ ...f, visible: toBool(fresh.public_stats ?? fresh.visible_in_stats) })); + setMsg("Änderungen gespeichert."); + + // Theme-Farben persistieren + anwenden + localStorage.setItem("order_bg1", bg1); + localStorage.setItem("order_bg2", bg2); + applyOrderColors(bg1, bg2); + + setMsg("Änderungen gespeichert."); + setCurrentPassword(""); + setNewPassword(""); + setNewPassword2(""); + } catch (e) { + setMsg(e?.message || "Fehler beim Speichern."); + } finally { + setLoading(false); + } + } + + async function handleNewPin() { + try { + await requestNewPin(); + setMsg("Neuer PIN wurde angefordert."); + } catch { + setMsg("Fehler beim Anfordern des PIN."); + } + } + + // --- Avatar --- + function onFileChange(e) { + const f = e.target.files?.[0]; + setFile(f || null); + if (preview) URL.revokeObjectURL(preview); + setPreview(f ? URL.createObjectURL(f) : null); + } + + async function onUploadAvatar() { + if (!file) { setMsg("Bitte zuerst eine Bilddatei auswählen."); return; } + setUploading(true); + setMsg(null); + try { + const res = await uploadAvatar(file); + if (res?.avatar_url) { + const abs = res.avatar_url.startsWith("http") ? res.avatar_url : `${API_BASE}${res.avatar_url}`; + // Cache-Bust + setAvatarUrl(`${abs}?v=${Date.now()}`); + setMsg("Profilbild aktualisiert."); + } else { + setMsg("Upload erfolgreich, aber keine Avatar-URL erhalten."); + } + setFile(null); + if (preview) URL.revokeObjectURL(preview); + setPreview(null); + } catch (e) { + setMsg("Fehler beim Hochladen des Profilbilds."); + } finally { + setUploading(false); + } + } + + function resetColors() { + setBg1(DEFAULT_BG1); + setBg2(DEFAULT_BG2); + applyOrderColors(DEFAULT_BG1, DEFAULT_BG2); + } + + const gradientStyle = { + background: `linear-gradient(135deg, ${bg1} 0%, ${bg2} 100%)`, + }; + + return ( +
+

Einstellungen

+ + {/* Panels */} +
+ {/* Profilbild */} + +
+
+ {preview ? ( + Preview + ) : avatarUrl ? ( + Avatar + ) : ( + Kein Bild + )} +
+ +
+ +

Empfohlen: quadratisch, ≤ 5 MB.

+
+ +
+ +
+
+
+ + {/* Theme für Order-Menü */} + +
+ + +
+ + {/* Live-Preview */} +
+ +
+ +
+ Wird gespeichert und sofort angewandt. +
+
+ + + {/* Profil-Angaben */} + + + +
+ In Statistiken sichtbar + +
+ + +
+ + {/* Sicherheit */} + +
+ setCurrentPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + /> + setNewPassword2(e.target.value)} + /> +
+ + {(newPassword || newPassword2 || currentPassword) && ( +
+ Zum Ändern muss das aktuelle Passwort korrekt eingegeben werden. +
+ )} + +
+ +
+
+
+ + {/* Save */} +
+ +
+ + {msg &&

{msg}

} +
+ ); +} + +/* ---------- Panel-Baustein ---------- */ +function Panel({ title, icon: Icon, children }) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} diff --git a/apps/frontend/src/management/pages/StatsPage.jsx b/apps/frontend/src/management/pages/StatsPage.jsx new file mode 100644 index 0000000..f1fed80 --- /dev/null +++ b/apps/frontend/src/management/pages/StatsPage.jsx @@ -0,0 +1,457 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { FiBarChart2, FiTable, FiUsers } from "react-icons/fi"; +import { + getStatsMeta, + getTopDrinkers as apiTopDrinkers, + getProductShare as apiProductShare, + API_BASE, +} from "../../api"; +import { + ResponsiveContainer, + BarChart, Bar, XAxis, YAxis, Tooltip, + PieChart, Pie, Cell, CartesianGrid, LabelList, +} from "recharts"; + +/* ---------- Utils ---------- */ +const euro = (c) => (Number(c ?? 0) / 100).toFixed(2); + +function useLocalState(key, initial) { + const [v, setV] = useState(() => { + try { const s = localStorage.getItem(key); return s ? JSON.parse(s) : initial; } catch { return initial; } + }); + useEffect(() => { try { localStorage.setItem(key, JSON.stringify(v)); } catch {} }, [key, v]); + return [v, setV]; +} + +const absMedia = (u) => { + if (!u) return ""; + // schon absolut? + if (/^https?:\/\//i.test(u)) return u; + // relativ -> an API_BASE anhängen + return `${API_BASE}${u}`; +}; + +const DEFAULT_AVATAR = "/avatar-default.png"; // liegt in /public + +function resolveAvatar(raw) { + const v = (raw ?? "").trim(); + if (!v) return DEFAULT_AVATAR; // nichts gesetzt + if (/^(https?:|data:|blob:|\/\/)/i.test(v)) return v; // absolute/data/blob + if (v === DEFAULT_AVATAR) return v; // Frontend-Asset nie präfixen + return `${API_BASE}${v.startsWith("/") ? v : `/${v}`}`; // Backend-relativ → präfixen +} + + +const PALETTE = ["#60a5fa", "#a78bfa", "#34d399", "#f59e0b", "#f472b6", "#22d3ee", "#fb7185", "#84cc16", "#c084fc", "#facc15"]; +const djb2 = (str) => { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i); return Math.abs(h); }; +const colorFor = (name) => PALETTE[djb2(String(name)) % PALETTE.length]; + +function aggregateTopN(items, n = 5) { + const sorted = [...items].sort((a, b) => b.value - a.value); + const top = sorted.slice(0, n); + const rest = sorted.slice(n); + const restSum = rest.reduce((s, it) => s + it.value, 0); + return restSum > 0 ? [...top, { name: "Sonstige", value: restSum, __other: true }] : top; +} + +/* ---------- Tooltips ---------- */ +function BarTip({ active, payload }) { + if (!active || !payload || !payload.length) return null; + const p = payload[0]?.payload; + return ( +
+
{p?.name}
+
{p?.count} Buchungen · {p?.value} Stück · {p?.euro} €
+
+ ); +} +function PieTip({ active, payload }) { + if (!active || !payload || !payload.length) return null; + const p = payload[0]; + return ( +
+
{p?.name}
+
Anzahl: {p?.value}
+
+ ); +} + +/* ---------- Avatar-Tick für Y-Achse ---------- */ +function makeYAvatarTick(avatarByName) { + return function YAvatarTick({ x, y, payload }) { + const url = avatarByName[payload?.value]; + const size = 20; + const cx = x - 10; // nach links in den Achsenbereich + const cy = y - size / 2; + return ( + + {url + ? + : } + + ); + }; +} + +/* ---------- Component ---------- */ +export default function StatsPage() { + const [meta, setMeta] = useState({ categories: [], last_delivery_at: null, visible_count: 0, hidden_count: 0, now: null }); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const [period, setPeriod] = useLocalState("stats.period", "last_delivery"); + const [view, setView] = useLocalState("stats.view", "chart"); // "chart" | "table" + const [category, setCategory] = useLocalState("stats.category", "Alle"); + + // Caches + const cacheTop = useRef(new Map()); // key `${period}::${category}` + const cacheShare = useRef(new Map()); // key `${period}` + + const [topRows, setTopRows] = useState([]); + const [share, setShare] = useState({ total: 0, items: [] }); + + /* ---- Aufbereitete Daten (Hooks VOR early-returns!) ---- */ + const topChartData = useMemo( + () => topRows + .slice(0, 10) + .map(r => ({ + name: r.alias, + value: r.amount_sum, + avatar_url: resolveAvatar(r.avatar_url), + count: r.count, + euro: euro(r.revenue_cents), + })), + [topRows] + ); + + + + const avatarByName = useMemo(() => { + const m = Object.create(null); + for (const r of topChartData) m[r.name] = r.avatar_url || ""; + return m; + }, [topChartData]); + + const pieDataRaw = useMemo( + () => (share.items || []).map((it) => ({ name: it.product_name, value: it.count })), + [share] + ); + const pieData = useMemo(() => aggregateTopN(pieDataRaw, 5), [pieDataRaw]); + + /* ---- Daten laden ---- */ + useEffect(() => { + let alive = true; + (async () => { + try { + const m = await getStatsMeta(); + if (!alive) return; + setMeta(m || {}); + const cats = Array.isArray(m?.categories) ? m.categories : []; + if (category !== "Alle" && !cats.includes(category)) setCategory("Alle"); + } catch (e) { + if (alive) setErr(e); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let alive = true; + const catParam = category === "Alle" ? "all" : category; + (async () => { + const kTop = `${period}::${catParam}`; + if (cacheTop.current.has(kTop)) { + setTopRows(cacheTop.current.get(kTop)); + } else { + try { + const rows = await apiTopDrinkers({ period, category: catParam, limit: 10 }); // Top 10 + if (!alive) return; + cacheTop.current.set(kTop, rows || []); + setTopRows(rows || []); + } catch { + if (alive) setTopRows([]); + } + } + const kShare = `${period}`; + if (cacheShare.current.has(kShare)) { + setShare(cacheShare.current.get(kShare)); + } else { + try { + const data = await apiProductShare({ period }); + if (!alive) return; + const norm = data && data.items ? data : { total: 0, items: [] }; + cacheShare.current.set(kShare, norm); + setShare(norm); + } catch { + if (alive) setShare({ total: 0, items: [] }); + } + } + })(); + return () => { alive = false; }; + }, [period, category]); + + const YAvatarTick = ({ x, y, payload }) => { + const name = String(payload?.value ?? ""); + const url = avatarByName[name] || ""; + const size = 55; + const r = size / 2; + + // stabiler, eindeutiger ID-String (keine Sonderzeichen) + const id = "clip-" + name.replace(/\W+/g, "") + "-" + Math.abs([...name].reduce((h,c)=>((h<<5)-h)+c.charCodeAt(0),0)); + + // Position: links der Achse + const imgX = (x - 12) - size; // Bild links ausrichten + const imgY = y - r; + const cx = imgX + r; // Kreiszentrum auf Bildmitte + const cy = imgY + r; + + return ( + + + + + + + + {url ? ( + + ) : ( + + )} + + ); + }; + + + + /* ---- Early returns ---- */ + if (loading) return
Lade Statistiken…
; + if (err) return
Fehler: {String(err.message || err)}
; + + const categories = ["Alle", ...(meta.categories || [])]; + + return ( +
+ {/* Header / Meta */} +
+
+ sichtbar: {meta.visible_count} + · + ausgeblendet: {meta.hidden_count} + {meta.last_delivery_at && ( + <> + · + letzte Lieferung: {new Date(meta.last_delivery_at).toLocaleString("de-DE")} + + )} +
+ +
+ {[ + ["last_delivery", "seit letzter Lieferung"], + ["ytd", "dieses Jahr"], + ["all", "gesamt"], + ].map(([k, label]) => ( + + ))} +
+ + +
+
+
+ + {/* Kategorie-Tabs */} +
+ {categories.map((c) => ( + + ))} +
+ + {/* Inhalt */} + {view === "chart" ? ( +
+ {/* Horizontales Ranking */} +
+

Top-Trinker ({category})

+ {topChartData.length === 0 ? ( +
Keine Daten für den Zeitraum.
+ ) : ( +
+ + + + + {/* Avatar statt Name */} + } + tickLine={false} + axisLine={false} + width={72} // vorher 36 + tickMargin={6} + /> + } /> + + + {topChartData.map((d) => )} + + + +
+ )} +
+ + {/* Donut + rechte Legende (Top-5 + Sonstige) */} +
+
+
+

Produktverteilung

+ {(pieData.length === 0 || share.total === 0) ? ( +
Keine Daten für den Zeitraum.
+ ) : ( +
+ + + + {pieData.map((s) => ( + + ))} + + } /> + + +
+
+
gesamt
+
{share.total}
+
+
+
+ )} +
+
+
    + {pieData.map((s) => ( +
  • + + {s.name} + {s.value} +
  • + ))} +
+
+
+
+
+ ) : ( + // Tabellenansicht +
+
+

Top-Trinker ({category})

+
+ + + + + + + + + + + {topRows.slice(0, 10).map((r) => ( + + + + + + + ))} + +
NutzerBuchungenStückUmsatz (€)
+
+ {r.avatar_url ? ( + {r.alias} { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }} + /> + ) : null} + {r.alias} +
+
{r.count}{r.amount_sum}{euro(r.revenue_cents)}
+
+
+ +
+

Produktverteilung

+
+ + + + + + + + + {pieData.map((p) => ( + + + + + ))} + {pieData.length === 0 && ( + + )} + +
ProduktAnzahl
{p.name}{p.value}
Keine Daten.
+
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/management/pages/TransactionPage.jsx b/apps/frontend/src/management/pages/TransactionPage.jsx new file mode 100644 index 0000000..ea9df5c --- /dev/null +++ b/apps/frontend/src/management/pages/TransactionPage.jsx @@ -0,0 +1,389 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + getCurrentUser, + getMyTransactions, + getMyTopups, + createTopup, +} from "../../api"; +import { + FiCreditCard, + FiSave, + FiCopy, + FiCheck, + FiExternalLink, + FiRefreshCw, +} from "react-icons/fi"; + +/* ---------- Geld & PayPal ---------- */ +const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }); + +function parseEuroToCents(input) { + if (typeof input !== "string") return null; + const s = input.trim().replace(/\s/g, "").replace(",", "."); + if (!s) return null; + const n = Number(s); + if (!isFinite(n)) return null; + const cents = Math.round(n * 100); + return cents > 0 ? cents : null; +} +function genCode5() { + return String(Math.floor(Math.random() * 100000)).padStart(5, "0"); +} +function buildPaypalUrl(amountCents, code) { + if (!amountCents || amountCents <= 0) return null; + const amountEuro = (amountCents / 100).toFixed(2); + const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim(); + const receiver = import.meta.env.VITE_PAYPAL_RECEIVER && String(import.meta.env.VITE_PAYPAL_RECEIVER).trim(); + if (me) { + return `https://www.paypal.me/${encodeURIComponent(me)}/${encodeURIComponent(amountEuro)}?locale.x=de_DE&country.x=DE¬e=${encodeURIComponent("Bacchus Top-Up " + (code || ""))}`; + } + if (receiver) { + const qs = new URLSearchParams({ + cmd: "_xclick", + business: receiver, + currency_code: "EUR", + amount: amountEuro, + item_name: `Bacchus Top-Up ${code || ""}`, + no_note: "1", + }); + return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`; + } + return null; +} + +/* ---------- Zeitformat (warum 2h verzug??) ---------- */ +const fmtBerlin = new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "medium", + timeZone: "Europe/Berlin", +}); +const toISOish = (s) => + typeof s === "string" + ? s.replace(" ", "T").replace(/(\.\d{3})\d+([Z+-])/, "$1$2").replace(/([+-]\d{2})$/, "$1:00") + : s; +function parseTs(s) { const d = new Date(toISOish(s)); return isNaN(d) ? null : d; } +const fmtTs = (s) => { const d = parseTs(s); return d ? fmtBerlin.format(d) : "—"; }; + +/* ---------- Normalisierung ---------- */ +function normStatus(s) { + if (!s) return ""; + if (typeof s === "string") return s.toLowerCase(); + if (typeof s === "object") { + const v = s.value ?? s.status ?? s.state ?? s.name; + return typeof v === "string" ? v.toLowerCase() : ""; + } + return String(s).toLowerCase(); +} +function normNote(n) { + if (n == null) return "—"; + if (typeof n === "string") return n; + if (typeof n === "object") { + if (typeof n.text === "string") return n.text; + if (typeof n.note === "string") return n.note; + try { return JSON.stringify(n); } catch { return String(n); } + } + return String(n); +} + +/* ---------- Mapper ---------- */ +function mapTopup(t) { + return { + id: t.id ?? t.topup_id ?? `topup-${t.code || Math.random()}`, + ts: t.created_at ?? t.timestamp, + amount_cents: typeof t.amount_cents === "number" ? t.amount_cents : null, + status: normStatus(t.status ?? t.state), + note: normNote(t.note ?? t.code), + source: "topup", + }; +} +function mapAdjustment(tx) { + const cents = + (typeof tx.amount_cents === "number" && tx.amount_cents) ?? + (typeof tx.delta_cents === "number" && tx.delta_cents) ?? null; + return { + id: tx.id ?? tx.tx_id ?? tx.transaction_id ?? `adj-${Math.random()}`, + ts: tx.created_at ?? tx.timestamp, + amount_cents: cents, + status: normStatus(tx.status ?? tx.state), + note: normNote(tx.note ?? tx.info), + source: "adjustment", + }; +} + +// Saldo-Anpassungen +function looksLikeAdjustment(tx) { + const type = String(tx.type || "").toLowerCase(); + if (type === "booking" || type === "topup") return false; + if (type.includes("adjust") || type.includes("balance")) return true; + + const hasBalanceFields = tx.new_balance_cents != null || tx.old_balance_cents != null; + const hasProductFields = tx.product_id != null || tx.product != null; + const hasPriceFields = tx.total_cents != null || tx.price_cents != null; + const amount = + (typeof tx.amount_cents === "number" && tx.amount_cents) ?? + (typeof tx.delta_cents === "number" && tx.delta_cents) ?? null; + + if (hasProductFields || hasPriceFields) return false; + if (typeof amount === "number" && amount < 0) return false; + return hasBalanceFields || typeof amount === "number"; +} + +/* ---------- Komponente ---------- */ +export default function TransactionPage() { + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [balanceCents, setBalanceCents] = useState(null); + + const [amountInput, setAmountInput] = useState(""); + const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]); + const [saved, setSaved] = useState(false); + const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); + + const [txLoading, setTxLoading] = useState(true); + const [txErr, setTxErr] = useState(null); + const [rows, setRows] = useState([]); + + useEffect(() => { + let alive = true; + (async () => { + try { + const me = await getCurrentUser(); + if (!alive) return; + setBalanceCents(typeof me?.balance_cents === "number" ? me.balance_cents : null); + } catch (e) { + if (alive) setErr(e?.message || "Konnte Guthaben nicht laden."); + } finally { + if (alive) setLoading(false); + } + })(); + reloadTx(); + return () => { alive = false; }; + }, []); + + async function reloadTx() { + setTxLoading(true); + setTxErr(null); + try { + const [topups, txs] = await Promise.all([ + getMyTopups({ limit: 50, offset: 0 }).catch(() => []), + getMyTransactions({ limit: 100, offset: 0 }).catch(() => []), + ]); + + const topupRows = (Array.isArray(topups) ? topups : (topups.items || [])).map(mapTopup); + + const adjustRows = (Array.isArray(txs) ? txs : (txs.items || [])) + .filter((t) => { + const type = String(t.type || "").toLowerCase(); + if (type === "booking" || type === "topup") return false; + return looksLikeAdjustment(t); + }) + .map(mapAdjustment); + + const merged = [...topupRows, ...adjustRows] + .filter((r) => r.amount_cents != null) + .sort((a, b) => (parseTs(b.ts)?.getTime() ?? 0) - (parseTs(a.ts)?.getTime() ?? 0)) + .slice(0, 20); + + setRows(merged); + } catch (e) { + setTxErr(e?.message || "Konnte Guthaben-Transaktionen nicht laden."); + } finally { + setTxLoading(false); + } + } + + const readyToSave = amountCents != null && amountCents > 0; + const paypalUrl = useMemo( + () => (saved ? buildPaypalUrl(amountCents, code) : null), + [saved, amountCents, code] + ); + + async function onSave() { + if (!readyToSave) return; + const newCode = genCode5(); + setSaved(true); + setCode(newCode); + setCopied(false); + try { + await createTopup(amountCents, newCode); // speichert Top-up + Code + await reloadTx(); + } catch (e) { + setTxErr(e?.message || "Top-up konnte nicht angelegt werden."); + } + } + async function onCopy() { + try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch {} + } + + return ( +
+

Transaktion / Aufladen

+ + {loading &&
Lade…
} + {err && !loading &&
{String(err)}
} + + {!loading && !err && ( +
+ {/* Guthaben */} + }> +
+ {typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"} +
+
Stand aus deinem Profil
+
+ + {/* Aufladen */} + +
+ + +
+ {amountCents ? `= ${euroFmt.format(amountCents / 100)}` : "—"} +
+ + +
+
+ Betrag eingeben und „Speichern“ klicken. +
+
+ + {/* Zahlungsdetails */} + {saved && ( + + + + )} + + {/* Tabelle */} + +
+
Zeigt die letzten 20 Einträge.
+ +
+ + {txLoading ? ( +
Lade…
+ ) : txErr ? ( +
{txErr}
+ ) : ( +
+ + + + + + + + + + + + {rows.map((r) => { + const amount = r.amount_cents != null ? euroFmt.format(r.amount_cents / 100) : "—"; + const s = r.status || ""; + const status = + s === "approved" || s === "completed" || s === "success" || s === "confirmed" + ? "genehmigt" + : s === "pending" || s === "waiting" + ? "wartend" + : s === "rejected" + ? "abgelehnt" + : (r.status || "—"); + return ( + + + + + + + + ); + })} + {!rows.length && ( + + )} + +
Transaktions-IDZeitBetragStatusNotiz
{r.id}{fmtTs(r.ts)}{amount} + {status === "genehmigt" ? ( + genehmigt + ) : status === "wartend" ? ( + wartend + ) : status === "abgelehnt" ? ( + abgelehnt + ) : ( + {status} + )} + {r.note ?? "—"}
Keine Einträge vorhanden.
+
+ )} +
+
+ )} +
+ ); +} + +/* ---------- UI-Baustein ---------- */ +function Card({ title, icon, children, className = "" }) { + return ( +
+ {(title || icon) && ( +
+ {icon} +

{title}

+
+ )} + {children} +
+ ); +} diff --git a/apps/frontend/src/management/pages/UsersPage.jsx b/apps/frontend/src/management/pages/UsersPage.jsx new file mode 100644 index 0000000..7bdc828 --- /dev/null +++ b/apps/frontend/src/management/pages/UsersPage.jsx @@ -0,0 +1,981 @@ +import React, { useEffect, useMemo, useState, useRef } from "react"; +import { + listUsers, + getUserById, + createUser, + updateUser, + deleteUser, + setUserPin, + setUserPassword, + adjustUserBalance, + listBookings, + getProducts +} from "../../api"; +import { FiEdit2 } from "react-icons/fi"; + +function cx(...arr) { + return arr.filter(Boolean).join(" "); +} + +function euro(cents) { + if (typeof cents !== "number") return "—"; + return (cents / 100).toFixed(2) + " €"; +} + +const defaultParams = { + q: "", + role: "", + active: "", + balance_lt: "", + limit: 25, + offset: 0, + sort: "name", + order: "asc", +}; + +function NiceDropdown({ + value, + onChange, + options, + placeholder = "Auswählen…", + className = "", + buttonClassName = "", + minWidth = 160, +}) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + const current = useMemo( + () => options?.find((o) => String(o.value) === String(value)) ?? null, + [options, value] + ); + + useEffect(() => { + function onDoc(e) { + if (!open) return; + if (popRef.current?.contains(e.target)) return; + if (btnRef.current?.contains(e.target)) return; + setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + return ( +
+ + + {open && ( +
+ {options.map((opt) => { + const active = String(opt.value) === String(value); + return ( + + ); + })} +
+ )} +
+ ); +} + +export default function UsersPage() { + // Query-State + const [params, setParams] = useState(defaultParams); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + // Drawer-State + const [open, setOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailErr, setDetailErr] = useState(null); + const [dirty, setDirty] = useState(false); + + // Viewer-State + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerUser, setViewerUser] = useState(null); + const [viewerBookings, setViewerBookings] = useState([]); + const [viewerLoading, setViewerLoading] = useState(false); + const [viewerErr, setViewerErr] = useState(null); + + // Forms + const [formBase, setFormBase] = useState({ + name: "", email: "", alias: "", paypal_email: "", + role: "user", is_active: true, + }); + const [formSec, setFormSec] = useState({ pin: "", password: "" }); + const [formAdj, setFormAdj] = useState({ amount_eur: "", reason: "" }); + + // Create modal + const [creating, setCreating] = useState(false); + const [createForm, setCreateForm] = useState({ + name: "", email: "", alias: "", paypal_email: "", + role: "user", password: "", pin: "", + }); + const [createErr, setCreateErr] = useState(null); + const [busy, setBusy] = useState(false); + + // --- Daten laden: Liste --- + useEffect(() => { + let alive = true; + setLoading(true); setErr(null); + (async () => { + try { + const data = await listUsers(cleanParams(params)); + if (!alive) return; + setRows(Array.isArray(data) ? data : []); + } catch (e) { + if (alive) setErr(e); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + }, [params.q, params.role, params.active, params.balance_lt, params.limit, params.offset, params.sort, params.order]); + + // --- Daten laden: Bearbeiten --- + useEffect(() => { + if (!open || !selectedId) return; + let alive = true; + setDetailLoading(true); setDetailErr(null); + (async () => { + try { + const data = await getUserById(selectedId); + if (!alive) return; + setDetail(data); + setFormBase({ + name: data.name ?? "", + email: data.email ?? "", + alias: data.alias ?? "", + paypal_email: data.paypal_email ?? "", + role: data.role ?? "user", + is_active: Boolean(data.is_active), + }); + setFormSec({ pin: "", password: "" }); + setFormAdj({ amount_eur: "", reason: "" }); + setDirty(false); + } catch (e) { + if (alive) setDetailErr(e); + } finally { + if (alive) setDetailLoading(false); + } + })(); + return () => { alive = false; }; + }, [open, selectedId]); + + const totalShown = rows.length; + + // --- Helpers --- + function setParam(k, v) { + setParams((p) => ({ ...p, [k]: v, ...(k !== "offset" ? { offset: 0 } : null) })); + } + function cleanParams(p) { + const out = {}; + for (const [k, v] of Object.entries(p)) if (v !== "" && v != null) out[k] = v; + return out; + } + function onSort(key) { + setParams((p) => { + const order = p.sort === key ? (p.order === "asc" ? "desc" : "asc") : "asc"; + return { ...p, sort: key, order, offset: 0 }; + }); + } + function openDetail(id) { setSelectedId(id); setOpen(true); } + function closeDetail(force=false) { + if (!force && dirty && !confirm("Nicht gespeicherte Änderungen verwerfen?")) return; + setOpen(false); setSelectedId(null); setDetail(null); setDetailErr(null); setDirty(false); + } + + +// --- Viewer --- +async function openViewer(id) { + setViewerOpen(true); + setViewerLoading(true); + setViewerErr(null); + setViewerUser(null); + setViewerBookings([]); + + try { + const user = await getUserById(id); + setViewerUser(user); + + const data = await listBookings({ user_id: id, limit: 10, offset: 0 }); + let list = Array.isArray(data) ? data : (data.items || data.results || data.data || []); + + list = list.filter(b => (b.user_id === id) || (b.user?.id === id)); + + const mapped = list.map(b => { + const total = + (typeof b.total_cents === "number" && b.total_cents) ?? + ((typeof b.price_cents === "number" ? b.price_cents : (b.product?.price_cents ?? 0)) * + (typeof b.amount === "number" ? b.amount : 1)); + + return { + id: b.id, + created_at: b.created_at || b.timestamp || null, // Zeitfeld ist optional + type: b.type || b.kind || "booking", + product_name: b.product_name || b.product?.name || `#${b.product_id ?? "?"}`, + amount_cents: total != null ? -Math.abs(Number(total)) : null, // Ausgaben negativ + }; + }); + + // Neueste zuerst; Fallback über ID + mapped.sort((a, b) => + (new Date(b.created_at || 0) - new Date(a.created_at || 0)) || + ((b.id ?? 0) - (a.id ?? 0)) + ); + + setViewerBookings(mapped.slice(0, 10)); + } catch (e) { + setViewerErr(e); + } finally { + setViewerLoading(false); + } +} + + + + + function closeViewer() { + setViewerOpen(false); + setViewerUser(null); + setViewerBookings([]); + setViewerErr(null); + } + + // --- Aktionen --- + async function saveBase() { + if (!selectedId) return; + setBusy(true); + try { + const payload = { + name: formBase.name, + email: formBase.email, + alias: formBase.alias, + paypal_email: formBase.paypal_email || null, + role: formBase.role, + is_active: formBase.is_active, + }; + const updated = await updateUser(selectedId, payload); + setDetail(updated); + setDirty(false); + setParams((p) => ({ ...p })); + } catch (e) { + alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler beim Speichern: ${e.message || e}`); + } finally { setBusy(false); } + } + + async function saveSecurity() { + if (!selectedId) return; + setBusy(true); + try { + if (formSec.pin && formSec.pin.length === 6) await setUserPin(selectedId, formSec.pin); + if (formSec.password && formSec.password.length >= 8) await setUserPassword(selectedId, formSec.password); + setFormSec({ pin: "", password: "" }); + alert("Sicherheitsdaten aktualisiert."); + } catch (e) { + alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler Sicherheit: ${e.message || e}`); + } finally { setBusy(false); } + } + + async function runAdjustment() { + if (!selectedId) return; + const cents = Math.round(parseFloat((formAdj.amount_eur || "").replace(",", ".")) * 100); + if (!Number.isFinite(cents) || cents === 0) { alert("Betrag ungültig oder 0."); return; } + if (!formAdj.reason || formAdj.reason.trim().length < 1) { alert("Bitte Grund angeben."); return; } + setBusy(true); + try { + await adjustUserBalance(selectedId, cents, formAdj.reason.trim()); + const data = await getUserById(selectedId); + setDetail(data); + setFormAdj({ amount_eur: "", reason: "" }); + setDirty(false); + setParams((p) => ({ ...p })); + } catch (e) { + alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler Kontokorrektur: ${e.message || e}`); + } finally { setBusy(false); } + } + + async function removeUser() { + if (!selectedId) return; + if (!confirm("Diesen Nutzer wirklich löschen?")) return; + setBusy(true); + try { + await deleteUser(selectedId); + closeDetail(true); + setParams((p) => ({ ...p })); + } catch (e) { + alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Löschen fehlgeschlagen: ${e.message || e}`); + } finally { setBusy(false); } + } + + async function createNewUser() { + const f = createForm; + if (!f.name || !f.email || !f.password || !f.pin) { setCreateErr("Name, E-Mail, Passwort und PIN sind Pflicht."); return; } + if (f.pin.length !== 6) { setCreateErr("PIN muss genau 6 Zeichen haben."); return; } + if (f.password.length < 8) { setCreateErr("Passwort mindestens 8 Zeichen."); return; } + setCreateErr(null); + setBusy(true); + try { + await createUser({ + name: f.name, email: f.email, + alias: f.alias || null, + paypal_email: f.paypal_email || null, + role: f.role || "user", + password: f.password, pin: f.pin, + }); + setCreating(false); + setCreateForm({ name:"", email:"", alias:"", paypal_email:"", role:"user", password:"", pin:"" }); + setParams((p) => ({ ...p })); + } catch (e) { + setCreateErr(e.status === 403 ? "Management-Login erforderlich." : (e.message || String(e))); + } finally { setBusy(false); } + } + + const sortIcon = useMemo(() => { + return (key) => params.sort === key ? ( + {params.order === "asc" ? "▲" : "▼"} + ) : null; + }, [params.sort, params.order]); + + return ( +
+ {/* Gesamtkasten mit integrierter Toolbar + Tabelle + Pagination */} +
+ {/* Toolbar oben im Kasten */} +
+

Nutzerverwaltung

+ +
+ {/* Suche */} +
+ setParam("q", e.target.value)} + /> +
+ + + + {/* Rollen-Filter */} + setParam("role", v)} + placeholder="Alle Rollen" + options={[ + { value: "", label: "Alle Rollen" }, + { value: "user", label: "User" }, + { value: "manager", label: "Manager" }, + { value: "admin", label: "Admin" }, + ]} + /> + + {/* Aktiv-Filter */} + setParam("active", v)} + placeholder="Aktiv/Inaktiv" + options={[ + { value: "", label: "Alle" }, + { value: "true", label: "Aktiv" }, + { value: "false", label: "Inaktiv" }, + ]} + /> + + + + {/* Quickfilter: Kontostand < 0 */} + + + {/* Nutzer anlegen */} + +
+
+ + {/* Tabelle */} +
+ + + + + + + + + + + + + + {loading ? ( + + ) : err ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((u) => ( + + + + + + + + + + )) + )} + +
onSort("name")} active={params.sort === "name"} order={params.order}>Name onSort("email")} active={params.sort === "email"} order={params.order}>E-MailAlias onSort("role")} active={params.sort === "role"} order={params.order}>Rolle onSort("balance_cents")} active={params.sort === "balance_cents"} order={params.order}>Kontostand onSort("is_active")} active={params.sort === "is_active"} order={params.order}>StatusAktionen
Lade…
Fehler: {String(err.message || err)}
Keine Nutzer gefunden.
{u.name}{u.email}{u.alias || "—"}{u.role} + {euro(u.balance_cents)} + {u.is_active ? "aktiv" : "inaktiv"} +
+ {/* Anzeigen (Auge) */} + + + {/* Bearbeiten (Stift / FiEdit2) */} + +
+
+
+ + {/* Pagination im Kasten */} +
+
Zeige {totalShown} Einträge
+
+ + +
+
+
+ + { setFormBase(f => ({ ...f, name: v })); setDirty(true); }} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + + {/* Drawer (Bearbeiten) – fixed */} + {open && ( +
+ {/* 60% transparenter Hintergrund */} +
closeDetail(false)} /> + + {/* Panel */} +
+ {/* Header */} +
+

Nutzer bearbeiten

+ +
+ + {/* Inhalt */} +
+ {detailLoading ? ( +
Lade Details…
+ ) : detailErr ? ( +
Fehler: {String(detailErr.message || detailErr)}
+ ) : !detail ? ( +
Keine Daten.
+ ) : ( + <> + {/* Stammdaten */} +
+

Daten

+
+ { setFormBase(f => ({ ...f, name: v })); setDirty(true); }} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + { setFormBase(f => ({ ...f, email: v })); setDirty(true); }} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + { setFormBase(f => ({ ...f, alias: v })); setDirty(true); }} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + { setFormBase(f => ({ ...f, paypal_email: v })); setDirty(true); }} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + + {/* Rolle */} +
+
Rolle
+ { setFormBase(f => ({ ...f, role: v })); setDirty(true); }} + placeholder="Rolle wählen" + options={[ + { value: "user", label: "User" }, + { value: "manager", label: "Manager" }, + { value: "admin", label: "Admin" }, + ]} + minWidth={200} + buttonClassName="bg-gray-800 text-gray-100 border-gray-600 hover:bg-gray-700" + /> +
+ + {/* Status */} +
+
Status
+ { setFormBase(f => ({ ...f, is_active: v === "true" })); setDirty(true); }} + placeholder="Status wählen" + options={[ + { value: "true", label: "Aktiv" }, + { value: "false", label: "Inaktiv" }, + ]} + minWidth={200} + buttonClassName="bg-gray-800 text-gray-100 border-gray-600 hover:bg-gray-700" + /> +
+
+ +
+ + +
+
+ + {/* Sicherheit */} +
+

Sicherheit

+
+ setFormSec(s => ({ ...s, pin: v }))} + maxLength={6} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + setFormSec(s => ({ ...s, password: v }))} + type="password" + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> +
+ +
+ + {/* Konto */} +
+

Konto

+
+ Aktueller Kontostand: {euro(detail.balance_cents)} +
+
+ setFormAdj(a => ({ ...a, amount_eur: v }))} + placeholder="+10.00" + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> + setFormAdj(a => ({ ...a, reason: v }))} + inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40" + /> +
+ +
+ + {/* Gefahrzone */} +
+

+ +
+ + )} +
+
+
+ )} + + {/* Viewer Modal (Read-Only) */} + {viewerOpen && ( +
+
+
+
+

Nutzerdaten & Buchungen

+ +
+ + {viewerLoading ? ( +
Lade…
+ ) : viewerErr ? ( +
Fehler: {String(viewerErr.message || viewerErr)}
+ ) : !viewerUser ? ( +
Keine Daten.
+ ) : ( +
+ {/* Stammdaten */} +
+ + + + + + + + +
+ + {/* Buchungen */} +
+
Letzte Buchungen
+
+ + + + + + + + + + + {Array.isArray(viewerBookings) && viewerBookings.length > 0 ? viewerBookings.map((b) => ( + + + + + + + )) : ( + + )} + +
ZeitTypProduktBetrag
{formatDate(b.created_at)}{b.type || b.kind || "—"}{b.product_name || (b.product_id != null ? `#${b.product_id}` : "—")} + {typeof b.amount_cents === "number" ? euro(b.amount_cents) : "—"} +
Keine Buchungen gefunden.
+
+
+
+ )} +
+
+ )} + + {/* Create Modal */} + {creating && ( +
+
setCreating(false)} /> +
+
+

Nutzer anlegen

+ +
+
+ setCreateForm(f => ({ ...f, name: v }))} /> + setCreateForm(f => ({ ...f, email: v }))} /> + setCreateForm(f => ({ ...f, alias: v }))} /> + setCreateForm(f => ({ ...f, paypal_email: v }))} /> + setCreateForm(f => ({ ...f, role: v }))} + options={[ + { value: "user", label: "user" }, + { value: "manager", label: "manager" }, + { value: "admin", label: "admin" }, + ]} + /> + setCreateForm(f => ({ ...f, password: v }))} /> + setCreateForm(f => ({ ...f, pin: v }))} maxLength={6} /> +
+ {createErr &&
{createErr}
} +
+ + +
+
+
+ )} +
+ ); +} + +/* ---------- kleine UI-Bausteine ---------- */ + +function Th({ children, onClick, active, order }) { + return ( + + + {children} + {active ? {order === "asc" ? "▲" : "▼"} : null} + + + ); +} + +function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) { + return ( + + ); +} + +function SelectField({ label, value, onChange, options }) { + return ( + + ); +} + +function ReadField({ label, value }) { + return ( +
+
{label}
+
{value ?? "—"}
+
+ ); +} + +function formatDate(s) { + if (!s) return "—"; + try { + const d = new Date(s); + return d.toLocaleString(); + } catch { return String(s); } +} + +function SimpleDropdown({ value, onChange, options, placeholder }) { + const [open, setOpen] = React.useState(false); + + return ( +
+ + + {open && ( +
+
    + {options.map(opt => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css new file mode 100644 index 0000000..dbc1342 --- /dev/null +++ b/apps/frontend/src/styles.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, body { + @apply bg-black text-white font-sans; + } +} diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js new file mode 100644 index 0000000..b0e15d8 --- /dev/null +++ b/apps/frontend/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + fontFamily: { + sans: [ '"Exo 2"', 'ui-sans-serif', 'system-ui'] + }, + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))' + } + }, + }, + plugins: [], + } \ No newline at end of file diff --git a/apps/frontend/vite.config.js b/apps/frontend/vite.config.js new file mode 100644 index 0000000..07221ff --- /dev/null +++ b/apps/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + open: true + } +});