diff --git a/infra/docker-compose.override.yml b/infra/docker-compose.override.yml index fa850f7..a592764 100644 --- a/infra/docker-compose.override.yml +++ b/infra/docker-compose.override.yml @@ -25,6 +25,8 @@ services: - "--providers.docker=true" - "--providers.docker.exposedByDefault=false" - "--providers.docker.network=proxy" + - "--providers.file.directory=/etc/traefik/dynamic" + - "--providers.file.watch=true" - "--entrypoints.web.address=:80" ports: !override - "80:80" @@ -36,7 +38,7 @@ services: - "traefik.http.routers.py-genai-helper.entrypoints=web" - "traefik.http.routers.py-genai-helper.rule=PathPrefix(`/api/v1/helper`)" - "traefik.http.middlewares.helper-stripprefix.stripprefix.prefixes=/api/v1/helper" - - "traefik.http.routers.py-genai-helper.middlewares=helper-stripprefix" + - "traefik.http.routers.py-genai-helper.middlewares=helper-stripprefix,forward-auth@file" - "traefik.http.services.py-genai-helper.loadbalancer.server.port=5000" organization-service: @@ -47,7 +49,7 @@ services: - "traefik.http.routers.organization-service.entrypoints=web" - "traefik.http.routers.organization-service.rule=PathPrefix(`/api/v1/organization`)" - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization" - - "traefik.http.routers.organization-service.middlewares=organization-stripprefix" + - "traefik.http.routers.organization-service.middlewares=organization-stripprefix,forward-auth@file" - "traefik.http.services.organization-service.loadbalancer.server.port=8080" member-service: @@ -58,7 +60,7 @@ services: - "traefik.http.routers.member-service.entrypoints=web" - "traefik.http.routers.member-service.rule=PathPrefix(`/api/v1/members`)" - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members" - - "traefik.http.routers.member-service.middlewares=member-stripprefix" + - "traefik.http.routers.member-service.middlewares=member-stripprefix,forward-auth@file" - "traefik.http.services.member-service.loadbalancer.server.port=8080" event-service: @@ -69,7 +71,7 @@ services: - "traefik.http.routers.event-service.entrypoints=web" - "traefik.http.routers.event-service.rule=PathPrefix(`/api/v1/events`)" - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events" - - "traefik.http.routers.event-service.middlewares=event-stripprefix" + - "traefik.http.routers.event-service.middlewares=event-stripprefix,forward-auth@file" - "traefik.http.services.event-service.loadbalancer.server.port=8080" feedback-service: @@ -80,7 +82,7 @@ services: - "traefik.http.routers.feedback-service.entrypoints=web" - "traefik.http.routers.feedback-service.rule=PathPrefix(`/api/v1/feedback`)" - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback" - - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix" + - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix,forward-auth@file" - "traefik.http.services.feedback-service.loadbalancer.server.port=8080" finance-service: @@ -91,7 +93,7 @@ services: - "traefik.http.routers.finance-service.entrypoints=web" - "traefik.http.routers.finance-service.rule=PathPrefix(`/api/v1/finance`)" - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance" - - "traefik.http.routers.finance-service.middlewares=finance-stripprefix" + - "traefik.http.routers.finance-service.middlewares=finance-stripprefix,forward-auth@file" - "traefik.http.services.finance-service.loadbalancer.server.port=8080" letter-service: @@ -102,7 +104,7 @@ services: - "traefik.http.routers.letter-service.entrypoints=web" - "traefik.http.routers.letter-service.rule=PathPrefix(`/api/v1/letters`)" - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters" - - "traefik.http.routers.letter-service.middlewares=letter-stripprefix" + - "traefik.http.routers.letter-service.middlewares=letter-stripprefix,forward-auth@file" - "traefik.http.services.letter-service.loadbalancer.server.port=8080" api-docs: @@ -120,13 +122,21 @@ services: - "traefik.enable=true" - "traefik.http.routers.web-client.entrypoints=web" - "traefik.http.routers.web-client.rule=PathPrefix(`/`)" + - "traefik.http.routers.web-client.middlewares=forward-auth@file" - "traefik.http.services.web-client.loadbalancer.server.port=8080" keycloak: + labels: !override + - "traefik.enable=true" + - "traefik.http.routers.keycloak.entrypoints=web" + - "traefik.http.routers.keycloak.rule=PathPrefix(`/auth`)" + - "traefik.http.services.keycloak.loadbalancer.server.port=8080" environment: KC_HOSTNAME: "http://localhost:8081/auth" traefik-forward-auth: + extra_hosts: + - "localhost:host-gateway" labels: !override - "traefik.enable=false" environment: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 99c83db..037e043 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -365,10 +365,23 @@ services: networks: - data + ollama: + image: ollama/ollama:latest + container_name: ollama + expose: + - 11434 + volumes: + - ollama_data:/root/.ollama + - ./ollama/entrypoint_ollama.sh:/entrypoint_ollama.sh + networks: + - proxy + entrypoint: ["/usr/bin/bash", "/entrypoint_ollama.sh"] + volumes: member_db_data: keycloak_db_data: letsencrypt: + ollama_data: networks: proxy: diff --git a/infra/helm/team-devoops/files/realm-config.json b/infra/helm/team-devoops/files/realm-config.json index f9648eb..c74075a 100644 --- a/infra/helm/team-devoops/files/realm-config.json +++ b/infra/helm/team-devoops/files/realm-config.json @@ -76,6 +76,7 @@ "webOrigins": [ "https://team-devoops.uaenorth.cloudapp.azure.com", "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de", + "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de", "http://localhost" ] } diff --git a/infra/helm/team-devoops/templates/ingress.yaml b/infra/helm/team-devoops/templates/ingress.yaml index 8e25ca1..02356d2 100644 --- a/infra/helm/team-devoops/templates/ingress.yaml +++ b/infra/helm/team-devoops/templates/ingress.yaml @@ -2,11 +2,13 @@ {{- $host := .Values.ingress.host }} {{- $tls := .Values.ingress.tls }} {{- $fa := .Values.forwardAuth }} +{{- $fa := .Values.forwardAuth }} # --------------------------------------------------------------------------- # Stripped ingress: services whose path prefix must be removed before the # request reaches the backend (Traefik stripPrefix parity). Uses a regex # capture group so `/api/v1/members/foo` -> `/foo`. # Auth-protected when forwardAuth is enabled. +# Auth-protected when forwardAuth is enabled. # --------------------------------------------------------------------------- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -53,6 +55,7 @@ spec: # --------------------------------------------------------------------------- # Plain ingress: services served at their path as-is (web-client, api-docs). # Auth-protected when forwardAuth is enabled. +# Auth-protected when forwardAuth is enabled. # --------------------------------------------------------------------------- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -61,6 +64,7 @@ metadata: labels: {{- include "team-devoops.labels" (dict "name" "ingress-plain" "root" $) | nindent 4 }} annotations: + {{- if and $tls.enabled $tls.clusterIssuer }} {{- if and $tls.enabled $tls.clusterIssuer }} cert-manager.io/cluster-issuer: {{ $tls.clusterIssuer | quote }} {{- end }} @@ -100,6 +104,35 @@ spec: # --------------------------------------------------------------------------- apiVersion: networking.k8s.io/v1 kind: Ingress +metadata: + name: team-devoops-open + labels: + {{- include "team-devoops.labels" (dict "name" "ingress-open" "root" $) | nindent 4 }} + {{- if and $tls.enabled $tls.clusterIssuer }} + annotations: + cert-manager.io/cluster-issuer: {{ $tls.clusterIssuer | quote }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if $tls.enabled }} + tls: + - hosts: + - {{ $host | quote }} + {{- if $tls.secretName }} + secretName: {{ $tls.secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ $host | quote }} + http: + paths: +--- +# --------------------------------------------------------------------------- +# Open ingress: Keycloak (auth provider) and the forward-auth OAuth callback +# must never be behind forward-auth to avoid redirect loops. +# --------------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: Ingress metadata: name: team-devoops-open labels: diff --git a/infra/keycloak/realm-config.json b/infra/keycloak/realm-config.json index f9648eb..c74075a 100644 --- a/infra/keycloak/realm-config.json +++ b/infra/keycloak/realm-config.json @@ -76,6 +76,7 @@ "webOrigins": [ "https://team-devoops.uaenorth.cloudapp.azure.com", "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de", + "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de", "http://localhost" ] } diff --git a/infra/ollama/entrypoint_ollama.sh b/infra/ollama/entrypoint_ollama.sh new file mode 100644 index 0000000..78da65a --- /dev/null +++ b/infra/ollama/entrypoint_ollama.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Start Ollama in the background. +/bin/ollama serve & +# Record Process ID. +pid=$! + +# Pause for Ollama to start. +sleep 5 + +echo "Retrieve model..." +ollama pull qwen3:8b +ollama pull nomic-embed-text +echo "Done!" + +# Wait for Ollama process to finish. +wait $pid \ No newline at end of file diff --git a/services/py-genai-helper/Dockerfile b/services/py-genai-helper/Dockerfile index 0c4da64..d4c045a 100644 --- a/services/py-genai-helper/Dockerfile +++ b/services/py-genai-helper/Dockerfile @@ -34,4 +34,4 @@ EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=3s --start-period=15s CMD wget -qO- http://127.0.0.1:5000/health || exit 1 # Run app -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "300", "app:app"] diff --git a/services/py-genai-helper/app.py b/services/py-genai-helper/app.py index f8ccd5a..94f98e6 100644 --- a/services/py-genai-helper/app.py +++ b/services/py-genai-helper/app.py @@ -27,3 +27,15 @@ def rag_response(): response = generate_rag_response(question) return {"response": response}, 200 + +@app.route("/rag-response-local", methods=["POST"]) +def rag_response_local(): + # Get the json of the object. force=True ignores the stated MimeType + data = request.get_json(force=True) or {} + question = data.get("question") + + if not question: + return {"error": "Missing required field: 'question'"}, 400 + + response = generate_rag_response(question, local=True) + return {"response": response}, 200 diff --git a/services/py-genai-helper/rag.py b/services/py-genai-helper/rag.py index 903d516..192dc0a 100644 --- a/services/py-genai-helper/rag.py +++ b/services/py-genai-helper/rag.py @@ -5,17 +5,16 @@ from langchain_community.document_loaders import PyPDFLoader from langchain_community.vectorstores import FAISS from langchain_core.tools import create_retriever_tool +from langchain_ollama import ChatOllama, OllamaEmbeddings from langchain_openai import OpenAIEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter load_dotenv() -embeddings = OpenAIEmbeddings(model="text-embedding-3-large") - _FILE_STORAGE = Path(__file__).parent / "file-storage" -def _load_pdfs() -> FAISS | None: +def _load_pdfs(embeddings) -> FAISS | None: pdf_files = list(_FILE_STORAGE.glob("*.pdf")) if not pdf_files: return None @@ -29,10 +28,19 @@ def _load_pdfs() -> FAISS | None: return FAISS.from_documents(docs, embedding=embeddings) -vector_store = _load_pdfs() +_local_vector_store = _load_pdfs(OllamaEmbeddings(model="nomic-embed-text", base_url="http://ollama:11434")) +_remote_vector_store = _load_pdfs(OpenAIEmbeddings(model="text-embedding-3-large")) + +def get_rag_agent(local: bool): + global _local_vector_store, _remote_vector_store + if local: + vector_store = _local_vector_store + model = ChatOllama(model="qwen3:8b", base_url="http://ollama:11434", think=False) + else: + vector_store = _remote_vector_store + model = "gpt-4.1-mini" -def get_rag_agent(): if vector_store is None: raise RuntimeError("No PDFs found in file-storage/") @@ -45,7 +53,7 @@ def get_rag_agent(): ) rag_agent = create_agent( - model="gpt-4.1-mini", + model=model, tools=[retriever_tool], system_prompt=( "You are a helpful assistant." diff --git a/services/py-genai-helper/service.py b/services/py-genai-helper/service.py index 187b16f..c41b9a2 100644 --- a/services/py-genai-helper/service.py +++ b/services/py-genai-helper/service.py @@ -5,11 +5,18 @@ from rag import get_rag_agent load_dotenv() -agent = create_agent("gpt-4.1-mini") +_agent = None + + +def _get_agent(): + global _agent + if _agent is None: + _agent = create_agent("gpt-4.1-mini") + return _agent def hello(): - response = agent.invoke( + response = _get_agent().invoke( { "messages": [ SystemMessage( @@ -22,7 +29,7 @@ def hello(): return response["messages"][-1].content -def generate_rag_response(question): - rag_agent = get_rag_agent() +def generate_rag_response(question, local=False): + rag_agent = get_rag_agent(local) response = rag_agent.invoke({"messages": [{"role": "user", "content": question}]}) return response["messages"][-1].content diff --git a/web-client/package.json b/web-client/package.json index 1358ef7..db0c719 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -22,8 +22,11 @@ "dependencies": { "@fontsource/bebas-neue": "^5.2.7", "@fontsource/poppins": "^5.2.7", + "@hookform/resolvers": "^5.4.0", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5", + "@tanstack/react-query-devtools": "^5.101.0", "axios": "^1.16.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -32,13 +35,16 @@ "radix-ui": "^1.4.3", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-hook-form": "^7.77.0", "react-router-dom": "^7.15.1", "rolldown": "^1.0.2", "rollup": "^4.60.4", - "tslib": "^2.8.1", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", - "tw-animate-css": "^1.4.0" + "tslib": "^2.8.1", + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml index d681a79..74fdb89 100644 --- a/web-client/pnpm-lock.yaml +++ b/web-client/pnpm-lock.yaml @@ -14,12 +14,21 @@ importers: '@fontsource/poppins': specifier: ^5.2.7 version: 5.2.7 + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.77.0(react@19.2.6)) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.6) '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + '@tanstack/react-query': + specifier: ^5 + version: 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': + specifier: ^5.101.0 + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) axios: specifier: ^1.16.1 version: 1.16.1 @@ -44,6 +53,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + react-hook-form: + specifier: ^7.77.0 + version: 7.77.0(react@19.2.6) react-router-dom: specifier: ^7.15.1 version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -65,6 +77,12 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -561,6 +579,11 @@ packages: peerDependencies: hono: ^4 + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1728,6 +1751,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -1818,6 +1844,23 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/query-devtools@5.101.0': + resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==} + + '@tanstack/react-query-devtools@5.101.0': + resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==} + peerDependencies: + '@tanstack/react-query': ^5.101.0 + react: ^18 || ^19 + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -3354,6 +3397,12 @@ packages: peerDependencies: react: ^19.2.6 + react-hook-form@7.77.0: + resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4032,6 +4081,24 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@ampproject/remapping@2.3.0': @@ -4482,6 +4549,11 @@ snapshots: dependencies: hono: 4.12.23 + '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.6))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.77.0(react@19.2.6) + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5587,6 +5659,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -5655,6 +5729,21 @@ snapshots: tailwindcss: 4.3.0 vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + '@tanstack/query-core@5.101.0': {} + + '@tanstack/query-devtools@5.101.0': {} + + '@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-devtools': 5.101.0 + '@tanstack/react-query': 5.101.0(react@19.2.6) + react: 19.2.6 + + '@tanstack/react-query@5.101.0(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.2.6 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -7229,6 +7318,10 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 + react-hook-form@7.77.0(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): dependencies: react: 19.2.6 @@ -7904,3 +7997,9 @@ snapshots: zod@3.25.76: {} zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) diff --git a/web-client/src/App.tsx b/web-client/src/App.tsx index 872168e..5b18815 100644 --- a/web-client/src/App.tsx +++ b/web-client/src/App.tsx @@ -1,7 +1,6 @@ -import { AppRouter } from '@/app/router/AppRouter' +import { RouterProvider } from 'react-router-dom' +import { router } from '@/app/router/routes' -function App() { - return +export default function App() { + return } - -export default App diff --git a/web-client/src/app/layout/AppShell.tsx b/web-client/src/app/layout/AppShell.tsx new file mode 100644 index 0000000..cd4ba32 --- /dev/null +++ b/web-client/src/app/layout/AppShell.tsx @@ -0,0 +1,79 @@ +import { NavLink, Outlet } from 'react-router-dom' +import { LayoutGrid } from 'lucide-react' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { ThemeToggle } from '@/app/theme/ThemeToggle' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from '@/components/ui/sidebar' + +const NAV_ITEMS = [ + { to: '/members', label: 'Members' }, + { to: '/sport-events', label: 'Sport Events' }, + { to: '/payments', label: 'Payments' }, + { to: '/letters', label: 'Letters' }, + { to: '/organization', label: 'Organization' }, + { to: '/feedback', label: 'Feedback' }, + { to: '/helper', label: 'GenAI Helper' }, +] + +export function AppShell() { + return ( + + + +
+ + Sports Club Platform +
+
+

+ Team Devoops +

+
+
+ + + + {NAV_ITEMS.map(({ to, label }) => ( + + + {({ isActive }) => ( + + {label} + + )} + + + ))} + + + + +
+ +
+
+
+ + +
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/web-client/src/app/router/AppRouter.tsx b/web-client/src/app/router/AppRouter.tsx deleted file mode 100644 index f7047d0..0000000 --- a/web-client/src/app/router/AppRouter.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useEffect, useState } from 'react' -import { NavLink, Navigate, Route, Routes } from 'react-router-dom' -import { ArrowRight, LayoutGrid, Orbit, Sparkles } from 'lucide-react' -import { getEventsHello } from '@/features/events/api' -import { getFeedbackHello } from '@/features/feedback/api' -import { getLettersHello } from '@/features/letters/api' -import { getMembersHello, getMembersAdminHello } from '@/features/members/api' -import { getOrganizationHello } from '@/features/organization/api' -import { getPaymentsHello } from '@/features/payments/api' -import { ThemeToggle } from '@/app/theme/ThemeToggle' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarInset, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, - SidebarTrigger, -} from '@/components/ui/sidebar' - - -type ServicePlaceholderPageProps = { - title: string - loadMessage: () => Promise -} - -function ServicePlaceholderPage({ title, loadMessage }: ServicePlaceholderPageProps) { - const [message, setMessage] = useState(null) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let isMounted = true - - loadMessage() - .then((response) => { - if (isMounted) { - setMessage(response) - setError(null) - } - }) - .catch((err: unknown) => { - if (isMounted) { - setMessage(null) - setError(err instanceof Error ? err.message : 'Unknown error') - } - }) - .finally(() => { - if (isMounted) setLoading(false) - }) - - return () => { - isMounted = false - } - }, [loadMessage]) - - return ( -
- - -
- - Connected service -
-
- - {title} - - - The navigation and design system are wired up. This page is still a service - placeholder, but it now lives inside the new Sera-inspired shell and shadcn - components. - -
-
- -
-
-

- Endpoint status -

-
- {loading &&

Loading hello endpoint response...

} - {message &&

{message}

} - {error &&

Failed to load response: {error}

} -
-
-
-
-

- What changed -

-

- DaisyUI primitives are out, semantic tokens and reusable UI building blocks are in. -

-
-
- - Ready for real feature screens using the same component foundation. -
-
-
-
-
- - - - Migration Notes - - The shell is now driven by the Sera theme variables and shadcn components. - - - -
-

- Tokens -

-

Light and dark mode both resolve through CSS variables instead of DaisyUI themes.

-
-
-

- Components -

-

Navigation, cards, and actions use shadcn-style primitives with shared variants.

-
-
-

- Next build step -

-

Feature pages can now expand from this foundation without carrying DaisyUI utility debt forward.

-
-
-
-
- ) -} - -const NAV_ITEMS = [ - { to: '/members', label: 'Members' }, - { to: '/events', label: 'Events' }, - { to: '/payments', label: 'Payments' }, - { to: '/letters', label: 'Letters' }, - { to: '/organization', label: 'Organization' }, - { to: '/feedback', label: 'Feedback' }, -] - -export function AppRouter() { - return ( - - - -
- - Sports Club Platform -
-
-

- Team Devoops -

-
-
- - - - {NAV_ITEMS.map(({ to, label }) => ( - - - {({ isActive }) => ( - - {label} - - )} - - - ))} - - - - -
-
- Active shell - -
- -
-
-
- - -
- -
-
- - } /> - - - - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - -
-
-
- ) -} diff --git a/web-client/src/app/router/routes.tsx b/web-client/src/app/router/routes.tsx new file mode 100644 index 0000000..7899155 --- /dev/null +++ b/web-client/src/app/router/routes.tsx @@ -0,0 +1,26 @@ +import { createBrowserRouter, Navigate } from 'react-router-dom' +import { AppShell } from '@/app/layout/AppShell' +import { MembersPage } from '@/features/members' +import { SportEventsPage } from '@/features/sport-events' +import { PaymentsPage } from '@/features/payments' +import { LettersPage } from '@/features/letters' +import { OrganizationPage } from '@/features/organization' +import { FeedbackPage } from '@/features/feedback' +import { HelperPage } from '@/features/helper' + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'members', element: }, + { path: 'sport-events', element: }, + { path: 'payments', element: }, + { path: 'letters', element: }, + { path: 'organization', element: }, + { path: 'feedback', element: }, + { path: 'helper', element: }, + ], + }, +]) diff --git a/web-client/src/components/ui/ErrorMessage.tsx b/web-client/src/components/ui/ErrorMessage.tsx new file mode 100644 index 0000000..faf987e --- /dev/null +++ b/web-client/src/components/ui/ErrorMessage.tsx @@ -0,0 +1,10 @@ +import { AlertCircle } from 'lucide-react' + +export function ErrorMessage({ message }: { message: string }) { + return ( +
+ + {message} +
+ ) +} diff --git a/web-client/src/components/ui/LoadingSpinner.tsx b/web-client/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..0bb0c4a --- /dev/null +++ b/web-client/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,7 @@ +export function LoadingSpinner() { + return ( +
+
+
+ ) +} diff --git a/web-client/src/features/events/api.ts b/web-client/src/features/events/api.ts deleted file mode 100644 index e308447..0000000 --- a/web-client/src/features/events/api.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { eventsClient } from '@/features/events/client' - -export async function getEventsHello(): Promise { - const res = await eventsClient.get('/hello') - return res.data -} diff --git a/web-client/src/features/events/client.ts b/web-client/src/features/events/client.ts deleted file mode 100644 index 710d172..0000000 --- a/web-client/src/features/events/client.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createApiClient } from '@/lib/keycloak' - -export const eventsClient = createApiClient('/api/v1/events') diff --git a/web-client/src/features/feedback/client.ts b/web-client/src/features/feedback/api/client.ts similarity index 100% rename from web-client/src/features/feedback/client.ts rename to web-client/src/features/feedback/api/client.ts diff --git a/web-client/src/features/feedback/api.ts b/web-client/src/features/feedback/api/index.ts similarity index 59% rename from web-client/src/features/feedback/api.ts rename to web-client/src/features/feedback/api/index.ts index 80b939f..54f11e9 100644 --- a/web-client/src/features/feedback/api.ts +++ b/web-client/src/features/feedback/api/index.ts @@ -1,6 +1,10 @@ -import { feedbackClient } from '@/features/feedback/client' +export * from './client' +export * from './queries' + +import { feedbackClient } from './client' export async function getFeedbackHello(): Promise { const res = await feedbackClient.get('/hello') + return res.data } diff --git a/web-client/src/features/feedback/api/queries.ts b/web-client/src/features/feedback/api/queries.ts new file mode 100644 index 0000000..d53af6d --- /dev/null +++ b/web-client/src/features/feedback/api/queries.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { feedbackClient } from './client' +import type { Feedback, FeedbackCreate, FeedbackPartialUpdate, FeedbackSummary } from '../types' + +export const feedbackKeys = { + all: ['feedback'] as const, + detail: (id: string) => ['feedback', id] as const, + hello: ['feedback', 'hello'] as const, +} + +export function useFeedbackHello() { + return useQuery({ + queryKey: feedbackKeys.hello, + queryFn: () => feedbackClient.get('/hello').then(r => r.data), + }) +} + +export function useFeedbackList() { + return useQuery({ + queryKey: feedbackKeys.all, + queryFn: () => feedbackClient.get('/').then(r => r.data), + }) +} + +export function useFeedback(id: string) { + return useQuery({ + queryKey: feedbackKeys.detail(id), + queryFn: () => feedbackClient.get(`/${id}`).then(r => r.data), + enabled: !!id, + }) +} + +export function useCreateFeedback() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => feedbackClient.post('/', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: feedbackKeys.all }), + }) +} + +export function useUpdateFeedback() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, ...data }) => feedbackClient.patch(`/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: feedbackKeys.all }) + qc.invalidateQueries({ queryKey: feedbackKeys.detail(id) }) + }, + }) +} + +export function useDeleteFeedback() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: id => feedbackClient.delete(`/${id}`).then(() => undefined), + onSuccess: (_, id) => { + qc.invalidateQueries({ queryKey: feedbackKeys.all }) + qc.removeQueries({ queryKey: feedbackKeys.detail(id) }) + }, + }) +} diff --git a/web-client/src/features/feedback/index.ts b/web-client/src/features/feedback/index.ts new file mode 100644 index 0000000..4144de1 --- /dev/null +++ b/web-client/src/features/feedback/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { FeedbackPage } from './pages/FeedbackPage' diff --git a/web-client/src/features/feedback/pages/FeedbackPage.tsx b/web-client/src/features/feedback/pages/FeedbackPage.tsx new file mode 100644 index 0000000..9ac09d2 --- /dev/null +++ b/web-client/src/features/feedback/pages/FeedbackPage.tsx @@ -0,0 +1,12 @@ +import { useFeedbackHello } from '../api' + +export function FeedbackPage() { + const { data: hello } = useFeedbackHello() + + return ( +
+

Feedback

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/feedback/types/index.ts b/web-client/src/features/feedback/types/index.ts new file mode 100644 index 0000000..f8224e9 --- /dev/null +++ b/web-client/src/features/feedback/types/index.ts @@ -0,0 +1 @@ +export type { Feedback, FeedbackSummary, FeedbackCreate, FeedbackPartialUpdate } from '@/types' diff --git a/web-client/src/features/helper/api/client.ts b/web-client/src/features/helper/api/client.ts new file mode 100644 index 0000000..6f141c5 --- /dev/null +++ b/web-client/src/features/helper/api/client.ts @@ -0,0 +1,3 @@ +import { createApiClient } from '@/lib/keycloak' + +export const helperClient = createApiClient('/api/v1/helper') diff --git a/web-client/src/features/helper/api/index.ts b/web-client/src/features/helper/api/index.ts new file mode 100644 index 0000000..290cc9f --- /dev/null +++ b/web-client/src/features/helper/api/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './queries' diff --git a/web-client/src/features/helper/api/queries.ts b/web-client/src/features/helper/api/queries.ts new file mode 100644 index 0000000..68abbd3 --- /dev/null +++ b/web-client/src/features/helper/api/queries.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query' + +import { helperClient } from './client' + +export const helperKeys = { + hello: ['helper', 'hello'] as const, + report: (memberId: string) => ['helper', 'report', memberId] as const, +} + +export function useHelperHello() { + return useQuery({ + queryKey: helperKeys.hello, + queryFn: () => helperClient.get('/hello').then(r => r.data), + }) +} + +export function useMemberReport(memberId: string) { + return useQuery({ + queryKey: helperKeys.report(memberId), + queryFn: () => helperClient.get(`/report/${memberId}`).then(r => r.data), + enabled: !!memberId, + }) +} diff --git a/web-client/src/features/helper/index.ts b/web-client/src/features/helper/index.ts new file mode 100644 index 0000000..a4d8d01 --- /dev/null +++ b/web-client/src/features/helper/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { HelperPage } from './pages/HelperPage' diff --git a/web-client/src/features/helper/pages/HelperPage.tsx b/web-client/src/features/helper/pages/HelperPage.tsx new file mode 100644 index 0000000..3fd9cc3 --- /dev/null +++ b/web-client/src/features/helper/pages/HelperPage.tsx @@ -0,0 +1,12 @@ +import { useHelperHello } from '../api' + +export function HelperPage() { + const { data: hello } = useHelperHello() + + return ( +
+

GenAI Helper

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/helper/types/index.ts b/web-client/src/features/helper/types/index.ts new file mode 100644 index 0000000..c17499c --- /dev/null +++ b/web-client/src/features/helper/types/index.ts @@ -0,0 +1,3 @@ +export interface HelperReport { + content: string +} diff --git a/web-client/src/features/letters/client.ts b/web-client/src/features/letters/api/client.ts similarity index 100% rename from web-client/src/features/letters/client.ts rename to web-client/src/features/letters/api/client.ts diff --git a/web-client/src/features/letters/api.ts b/web-client/src/features/letters/api/index.ts similarity index 59% rename from web-client/src/features/letters/api.ts rename to web-client/src/features/letters/api/index.ts index c6f1b69..9057350 100644 --- a/web-client/src/features/letters/api.ts +++ b/web-client/src/features/letters/api/index.ts @@ -1,6 +1,10 @@ -import { lettersClient } from '@/features/letters/client' +export * from './client' +export * from './queries' + +import { lettersClient } from './client' export async function getLettersHello(): Promise { const res = await lettersClient.get('/hello') + return res.data } diff --git a/web-client/src/features/letters/api/queries.ts b/web-client/src/features/letters/api/queries.ts new file mode 100644 index 0000000..6701028 --- /dev/null +++ b/web-client/src/features/letters/api/queries.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery } from '@tanstack/react-query' + +import { lettersClient } from './client' +import type { GeneratePdfRequest, SendMailRequest } from '../types' + +export const lettersKeys = { + hello: ['letters', 'hello'] as const, +} + +export function useLettersHello() { + return useQuery({ + queryKey: lettersKeys.hello, + queryFn: () => lettersClient.get('/hello').then(r => r.data), + }) +} + +export function useSendMail() { + return useMutation({ + mutationFn: data => + lettersClient.post('/mail', data.html, { + headers: { 'Content-Type': 'text/html' }, + }).then(() => undefined), + }) +} + +export function useGeneratePdf() { + return useMutation({ + mutationFn: data => + lettersClient.post('/pdf', data.html, { + headers: { 'Content-Type': 'text/html' }, + responseType: 'blob', + }).then(r => r.data), + }) +} diff --git a/web-client/src/features/letters/index.ts b/web-client/src/features/letters/index.ts new file mode 100644 index 0000000..e878b99 --- /dev/null +++ b/web-client/src/features/letters/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { LettersPage } from './pages/LettersPage' diff --git a/web-client/src/features/letters/pages/LettersPage.tsx b/web-client/src/features/letters/pages/LettersPage.tsx new file mode 100644 index 0000000..f2fb8e3 --- /dev/null +++ b/web-client/src/features/letters/pages/LettersPage.tsx @@ -0,0 +1,12 @@ +import { useLettersHello } from '../api' + +export function LettersPage() { + const { data: hello } = useLettersHello() + + return ( +
+

Letters

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/letters/types/index.ts b/web-client/src/features/letters/types/index.ts new file mode 100644 index 0000000..3317698 --- /dev/null +++ b/web-client/src/features/letters/types/index.ts @@ -0,0 +1,7 @@ +export interface SendMailRequest { + html: string +} + +export interface GeneratePdfRequest { + html: string +} diff --git a/web-client/src/features/members/api.ts b/web-client/src/features/members/api.ts deleted file mode 100644 index d5ef2a9..0000000 --- a/web-client/src/features/members/api.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { membersClient } from '@/features/members/client' - -export async function getMembersHello(): Promise { - const res = await membersClient.get('/hello') - return res.data -} - -export async function getMembersAdminHello(): Promise { - try { - const res = await membersClient.get('/helloAdmin') - return res.data - } - catch { - return "You are not logged into an administrator account" - } -} diff --git a/web-client/src/features/members/client.ts b/web-client/src/features/members/api/client.ts similarity index 100% rename from web-client/src/features/members/client.ts rename to web-client/src/features/members/api/client.ts diff --git a/web-client/src/features/members/api/index.ts b/web-client/src/features/members/api/index.ts new file mode 100644 index 0000000..290cc9f --- /dev/null +++ b/web-client/src/features/members/api/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './queries' diff --git a/web-client/src/features/members/api/queries.ts b/web-client/src/features/members/api/queries.ts new file mode 100644 index 0000000..7a1d1d7 --- /dev/null +++ b/web-client/src/features/members/api/queries.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { membersClient } from './client' +import type { Member, MemberCreate, MemberPartialUpdate, MemberSummary } from '../types' + +export const membersKeys = { + hello: ['members', 'hello'] as const, + all: ['members'] as const, + detail: (id: string) => ['members', id] as const, +} + +export function useMembersHello() { + return useQuery({ + queryKey: membersKeys.hello, + queryFn: () => membersClient.get('/hello').then(r => r.data), + }) +} + +export function useMembers() { + return useQuery({ + queryKey: membersKeys.all, + queryFn: () => membersClient.get('/').then(r => r.data), + }) +} + +export function useMember(id: string) { + return useQuery({ + queryKey: membersKeys.detail(id), + queryFn: () => membersClient.get(`/${id}`).then(r => r.data), + enabled: !!id, + }) +} + +export function useCreateMember() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => membersClient.post('/', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: membersKeys.all }), + }) +} + +export function useUpdateMember() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, ...data }) => membersClient.patch(`/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: membersKeys.all }) + qc.invalidateQueries({ queryKey: membersKeys.detail(id) }) + }, + }) +} + +export function useDeleteMember() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: id => membersClient.delete(`/${id}`).then(() => undefined), + onSuccess: (_, id) => { + qc.invalidateQueries({ queryKey: membersKeys.all }) + qc.removeQueries({ queryKey: membersKeys.detail(id) }) + }, + }) +} diff --git a/web-client/src/features/members/index.ts b/web-client/src/features/members/index.ts new file mode 100644 index 0000000..8fc39e2 --- /dev/null +++ b/web-client/src/features/members/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { MembersPage } from './pages/MembersPage' diff --git a/web-client/src/features/members/pages/MembersPage.tsx b/web-client/src/features/members/pages/MembersPage.tsx new file mode 100644 index 0000000..c12ffd7 --- /dev/null +++ b/web-client/src/features/members/pages/MembersPage.tsx @@ -0,0 +1,12 @@ +import { useMembersHello } from '../api' + +export function MembersPage() { + const { data: hello } = useMembersHello() + + return ( +
+

Members

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/members/types/index.ts b/web-client/src/features/members/types/index.ts new file mode 100644 index 0000000..1684bbd --- /dev/null +++ b/web-client/src/features/members/types/index.ts @@ -0,0 +1 @@ +export type { Member, MemberSummary, MemberCreate, MemberPartialUpdate } from '@/types' diff --git a/web-client/src/features/organization/client.ts b/web-client/src/features/organization/api/client.ts similarity index 100% rename from web-client/src/features/organization/client.ts rename to web-client/src/features/organization/api/client.ts diff --git a/web-client/src/features/organization/api.ts b/web-client/src/features/organization/api/index.ts similarity index 59% rename from web-client/src/features/organization/api.ts rename to web-client/src/features/organization/api/index.ts index 76f6b0e..ce9415d 100644 --- a/web-client/src/features/organization/api.ts +++ b/web-client/src/features/organization/api/index.ts @@ -1,6 +1,10 @@ -import { organizationClient } from '@/features/organization/client' +export * from './client' +export * from './queries' + +import { organizationClient } from './client' export async function getOrganizationHello(): Promise { const res = await organizationClient.get('/hello') + return res.data } diff --git a/web-client/src/features/organization/api/queries.ts b/web-client/src/features/organization/api/queries.ts new file mode 100644 index 0000000..cbb4378 --- /dev/null +++ b/web-client/src/features/organization/api/queries.ts @@ -0,0 +1,122 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { organizationClient } from './client' +import type { + Sport, + SportCreate, + SportPartialUpdate, + Team, + TeamCreate, + TeamPartialUpdate, +} from '../types' + +export const organizationKeys = { + hello: ['organization', 'hello'] as const, + sports: ['organization', 'sports'] as const, + sport: (name: string) => ['organization', 'sports', name] as const, + teams: ['organization', 'teams'] as const, + team: (id: string) => ['organization', 'teams', id] as const, +} + +export function useOrganizationHello() { + return useQuery({ + queryKey: organizationKeys.hello, + queryFn: () => organizationClient.get('/hello').then(r => r.data), + }) +} + +export function useSports() { + return useQuery({ + queryKey: organizationKeys.sports, + queryFn: () => organizationClient.get('/sports').then(r => r.data), + }) +} + +export function useSport(name: string) { + return useQuery({ + queryKey: organizationKeys.sport(name), + queryFn: () => organizationClient.get(`/sports/${name}`).then(r => r.data), + enabled: !!name, + }) +} + +export function useCreateSport() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => organizationClient.post('/sports', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: organizationKeys.sports }), + }) +} + +export function useUpdateSport() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ name, ...data }) => organizationClient.patch(`/sports/${name}`, data).then(r => r.data), + onSuccess: (_, { name }) => { + qc.invalidateQueries({ queryKey: organizationKeys.sports }) + qc.invalidateQueries({ queryKey: organizationKeys.sport(name) }) + }, + }) +} + +export function useDeleteSport() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: name => organizationClient.delete(`/sports/${name}`).then(() => undefined), + onSuccess: (_, name) => { + qc.invalidateQueries({ queryKey: organizationKeys.sports }) + qc.removeQueries({ queryKey: organizationKeys.sport(name) }) + }, + }) +} + +export function useTeams() { + return useQuery({ + queryKey: organizationKeys.teams, + queryFn: () => organizationClient.get('/teams').then(r => r.data), + }) +} + +export function useTeam(id: string) { + return useQuery({ + queryKey: organizationKeys.team(id), + queryFn: () => organizationClient.get(`/teams/${id}`).then(r => r.data), + enabled: !!id, + }) +} + +export function useCreateTeam() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => organizationClient.post('/teams', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: organizationKeys.teams }), + }) +} + +export function useUpdateTeam() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, ...data }) => organizationClient.patch(`/teams/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: organizationKeys.teams }) + qc.invalidateQueries({ queryKey: organizationKeys.team(id) }) + }, + }) +} + +export function useDeleteTeam() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: id => organizationClient.delete(`/teams/${id}`).then(() => undefined), + onSuccess: (_, id) => { + qc.invalidateQueries({ queryKey: organizationKeys.teams }) + qc.removeQueries({ queryKey: organizationKeys.team(id) }) + }, + }) +} diff --git a/web-client/src/features/organization/index.ts b/web-client/src/features/organization/index.ts new file mode 100644 index 0000000..e74a24f --- /dev/null +++ b/web-client/src/features/organization/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { OrganizationPage } from './pages/OrganizationPage' diff --git a/web-client/src/features/organization/pages/OrganizationPage.tsx b/web-client/src/features/organization/pages/OrganizationPage.tsx new file mode 100644 index 0000000..0b180d8 --- /dev/null +++ b/web-client/src/features/organization/pages/OrganizationPage.tsx @@ -0,0 +1,12 @@ +import { useOrganizationHello } from '../api' + +export function OrganizationPage() { + const { data: hello } = useOrganizationHello() + + return ( +
+

Organization

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/organization/types/index.ts b/web-client/src/features/organization/types/index.ts new file mode 100644 index 0000000..028d19c --- /dev/null +++ b/web-client/src/features/organization/types/index.ts @@ -0,0 +1 @@ +export type { Sport, SportCreate, SportPartialUpdate, Team, TeamCreate, TeamPartialUpdate } from '@/types' diff --git a/web-client/src/features/payments/client.ts b/web-client/src/features/payments/api/client.ts similarity index 100% rename from web-client/src/features/payments/client.ts rename to web-client/src/features/payments/api/client.ts diff --git a/web-client/src/features/payments/api.ts b/web-client/src/features/payments/api/index.ts similarity index 59% rename from web-client/src/features/payments/api.ts rename to web-client/src/features/payments/api/index.ts index bb6f870..c625700 100644 --- a/web-client/src/features/payments/api.ts +++ b/web-client/src/features/payments/api/index.ts @@ -1,6 +1,10 @@ -import { paymentsClient } from '@/features/payments/client' +export * from './client' +export * from './queries' + +import { paymentsClient } from './client' export async function getPaymentsHello(): Promise { const res = await paymentsClient.get('/hello') + return res.data } diff --git a/web-client/src/features/payments/api/queries.ts b/web-client/src/features/payments/api/queries.ts new file mode 100644 index 0000000..0ef75cf --- /dev/null +++ b/web-client/src/features/payments/api/queries.ts @@ -0,0 +1,82 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { paymentsClient } from './client' +import type { Balance, Transaction, TransactionCreate, TransactionPartialUpdate } from '../types' + +export const paymentsKeys = { + hello: ['payments', 'hello'] as const, + balances: ['payments', 'balances'] as const, + balance: (memberId: string) => ['payments', 'balances', memberId] as const, + transactions: ['payments', 'transactions'] as const, + transaction: (id: string) => ['payments', 'transactions', id] as const, +} + +export function usePaymentsHello() { + return useQuery({ + queryKey: paymentsKeys.hello, + queryFn: () => paymentsClient.get('/hello').then(r => r.data), + }) +} + +export function useBalances() { + return useQuery({ + queryKey: paymentsKeys.balances, + queryFn: () => paymentsClient.get('/balances').then(r => r.data), + }) +} + +export function useMemberBalance(memberId: string) { + return useQuery({ + queryKey: paymentsKeys.balance(memberId), + queryFn: () => paymentsClient.get(`/balances/${memberId}`).then(r => r.data), + enabled: !!memberId, + }) +} + +export function useTransactions() { + return useQuery({ + queryKey: paymentsKeys.transactions, + queryFn: () => paymentsClient.get('/transactions').then(r => r.data), + }) +} + +export function useTransaction(id: string) { + return useQuery({ + queryKey: paymentsKeys.transaction(id), + queryFn: () => paymentsClient.get(`/transactions/${id}`).then(r => r.data), + enabled: !!id, + }) +} + +export function useCreateTransaction() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => paymentsClient.post('/transactions', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: paymentsKeys.transactions }), + }) +} + +export function useUpdateTransaction() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, ...data }) => paymentsClient.patch(`/transactions/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: paymentsKeys.transactions }) + qc.invalidateQueries({ queryKey: paymentsKeys.transaction(id) }) + }, + }) +} + +export function useDeleteTransaction() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: id => paymentsClient.delete(`/transactions/${id}`).then(() => undefined), + onSuccess: (_, id) => { + qc.invalidateQueries({ queryKey: paymentsKeys.transactions }) + qc.removeQueries({ queryKey: paymentsKeys.transaction(id) }) + }, + }) +} diff --git a/web-client/src/features/payments/index.ts b/web-client/src/features/payments/index.ts new file mode 100644 index 0000000..0158ec5 --- /dev/null +++ b/web-client/src/features/payments/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { PaymentsPage } from './pages/PaymentsPage' diff --git a/web-client/src/features/payments/pages/PaymentsPage.tsx b/web-client/src/features/payments/pages/PaymentsPage.tsx new file mode 100644 index 0000000..9ca646b --- /dev/null +++ b/web-client/src/features/payments/pages/PaymentsPage.tsx @@ -0,0 +1,12 @@ +import { usePaymentsHello } from '../api' + +export function PaymentsPage() { + const { data: hello } = usePaymentsHello() + + return ( +
+

Payments

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/payments/types/index.ts b/web-client/src/features/payments/types/index.ts new file mode 100644 index 0000000..6991f72 --- /dev/null +++ b/web-client/src/features/payments/types/index.ts @@ -0,0 +1 @@ +export type { Transaction, TransactionCreate, TransactionPartialUpdate, Balance } from '@/types' diff --git a/web-client/src/features/sport-events/api/client.ts b/web-client/src/features/sport-events/api/client.ts new file mode 100644 index 0000000..8706cd7 --- /dev/null +++ b/web-client/src/features/sport-events/api/client.ts @@ -0,0 +1,3 @@ +import { createApiClient } from '@/lib/keycloak' + +export const sportEventsClient = createApiClient('/api/v1/events') diff --git a/web-client/src/features/sport-events/api/index.ts b/web-client/src/features/sport-events/api/index.ts new file mode 100644 index 0000000..290cc9f --- /dev/null +++ b/web-client/src/features/sport-events/api/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './queries' diff --git a/web-client/src/features/sport-events/api/queries.ts b/web-client/src/features/sport-events/api/queries.ts new file mode 100644 index 0000000..97794bc --- /dev/null +++ b/web-client/src/features/sport-events/api/queries.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { sportEventsClient } from './client' +import type { SportEvent, EventCreate, EventPartialUpdate, EventSummary } from '../types' + +export const sportEventsKeys = { + hello: ['sport-events', 'hello'] as const, + all: ['sport-events'] as const, + detail: (id: string) => ['sport-events', id] as const, +} + +export function useSportEventsHello() { + return useQuery({ + queryKey: sportEventsKeys.hello, + queryFn: () => sportEventsClient.get('/hello').then(r => r.data), + }) +} + +export function useSportEvents() { + return useQuery({ + queryKey: sportEventsKeys.all, + queryFn: () => sportEventsClient.get('/').then(r => r.data), + }) +} + +export function useSportEvent(id: string) { + return useQuery({ + queryKey: sportEventsKeys.detail(id), + queryFn: () => sportEventsClient.get(`/${id}`).then(r => r.data), + enabled: !!id, + }) +} + +export function useCreateSportEvent() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: data => sportEventsClient.post('/', data).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: sportEventsKeys.all }), + }) +} + +export function useUpdateSportEvent() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, ...data }) => sportEventsClient.patch(`/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: sportEventsKeys.all }) + qc.invalidateQueries({ queryKey: sportEventsKeys.detail(id) }) + }, + }) +} + +export function useDeleteSportEvent() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: id => sportEventsClient.delete(`/${id}`).then(() => undefined), + onSuccess: (_, id) => { + qc.invalidateQueries({ queryKey: sportEventsKeys.all }) + qc.removeQueries({ queryKey: sportEventsKeys.detail(id) }) + }, + }) +} diff --git a/web-client/src/features/sport-events/index.ts b/web-client/src/features/sport-events/index.ts new file mode 100644 index 0000000..5be00ba --- /dev/null +++ b/web-client/src/features/sport-events/index.ts @@ -0,0 +1,3 @@ +export * from './api' +export * from './types' +export { SportEventsPage } from './pages/SportEventsPage' diff --git a/web-client/src/features/sport-events/pages/SportEventsPage.tsx b/web-client/src/features/sport-events/pages/SportEventsPage.tsx new file mode 100644 index 0000000..52b70cf --- /dev/null +++ b/web-client/src/features/sport-events/pages/SportEventsPage.tsx @@ -0,0 +1,12 @@ +import { useSportEventsHello } from '../api' + +export function SportEventsPage() { + const { data: hello } = useSportEventsHello() + + return ( +
+

Events

+ {hello &&

{hello}

} +
+ ) +} diff --git a/web-client/src/features/sport-events/types/index.ts b/web-client/src/features/sport-events/types/index.ts new file mode 100644 index 0000000..b29331c --- /dev/null +++ b/web-client/src/features/sport-events/types/index.ts @@ -0,0 +1 @@ +export type { SportEvent, EventSummary, EventCreate, EventPartialUpdate } from '@/types' diff --git a/web-client/src/lib/forms.ts b/web-client/src/lib/forms.ts new file mode 100644 index 0000000..edefd82 --- /dev/null +++ b/web-client/src/lib/forms.ts @@ -0,0 +1,3 @@ +export { useForm } from 'react-hook-form' +export { zodResolver } from '@hookform/resolvers/zod' +export { z } from 'zod' diff --git a/web-client/src/main.tsx b/web-client/src/main.tsx index a129fd4..c786e9f 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -1,10 +1,31 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AxiosError } from 'axios' import keycloak from '@/lib/keycloak' +import { ThemeProvider } from '@/app/theme/ThemeProvider' import '@/index.css' import App from './App.tsx' -import { ThemeProvider } from './app/theme/ThemeProvider' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + retry: (failureCount, error) => { + if ( + error instanceof AxiosError && + error.response && + error.response.status >= 400 && + error.response.status < 500 + ) { + return false + } + return failureCount < 2 + }, + }, + }, +}) keycloak.init({ onLoad: 'login-required', pkceMethod: 'S256' }).then((authenticated) => { if (!authenticated) { @@ -15,9 +36,9 @@ keycloak.init({ onLoad: 'login-required', pkceMethod: 'S256' }).then((authentica createRoot(document.getElementById('root')!).render( - + - + , ) diff --git a/web-client/src/store/ui.ts b/web-client/src/store/ui.ts new file mode 100644 index 0000000..8337041 --- /dev/null +++ b/web-client/src/store/ui.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +interface UIState { + activeTab: string | null + setActiveTab: (tab: string | null) => void +} + +export const useUIStore = create((set) => ({ + activeTab: null, + setActiveTab: (tab) => set({ activeTab: tab }), +})) diff --git a/web-client/src/types.ts b/web-client/src/types.ts new file mode 100644 index 0000000..ca5bb0b --- /dev/null +++ b/web-client/src/types.ts @@ -0,0 +1,31 @@ +import type { components } from './api' + +type S = components['schemas'] + +export type Member = S['Member'] +export type MemberSummary = S['MemberSummary'] +export type MemberCreate = S['MemberCreate'] +export type MemberPartialUpdate = S['MemberPartialUpdate'] + +export type SportEvent = S['Event'] +export type EventSummary = S['EventSummary'] +export type EventCreate = S['EventCreate'] +export type EventPartialUpdate = S['EventPartialUpdate'] + +export type Sport = S['Sport'] +export type SportCreate = S['SportCreate'] +export type SportPartialUpdate = S['SportPartialUpdate'] + +export type Team = S['Team'] +export type TeamCreate = S['TeamCreate'] +export type TeamPartialUpdate = S['TeamPartialUpdate'] + +export type Feedback = S['Feedback'] +export type FeedbackSummary = S['FeedbackSummary'] +export type FeedbackCreate = S['FeedbackCreate'] +export type FeedbackPartialUpdate = S['FeedbackPartialUpdate'] + +export type Transaction = S['Transaction'] +export type TransactionCreate = S['TransactionCreate'] +export type TransactionPartialUpdate = S['TransactionPartialUpdate'] +export type Balance = S['Balance']