diff --git a/project/.env.template b/project/.env.template index e7fe96c..6fbee81 100644 --- a/project/.env.template +++ b/project/.env.template @@ -2,4 +2,4 @@ API_KEY= DATABASE_URL=postgresql://admin:admin@db:5432 POSTGRES_PASSWORD=admin POSTGRES_USER=admin -COORDINATOR_URL="coordinator:5000" +COORDINATOR_URL="http://coordinator:5000" diff --git a/project/backend/coordinator/.dockerignore b/project/backend/coordinator/.dockerignore new file mode 100644 index 0000000..c07f7de --- /dev/null +++ b/project/backend/coordinator/.dockerignore @@ -0,0 +1,3 @@ +.venv +venv +__pycache__ diff --git a/project/backend/coordinator/app.py b/project/backend/coordinator/app.py index f810143..702abb9 100644 --- a/project/backend/coordinator/app.py +++ b/project/backend/coordinator/app.py @@ -21,6 +21,7 @@ app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB init_db(app) register_routes(app) + # Register blueprints app.register_blueprint(kennzahlen_bp) @@ -28,6 +29,7 @@ app.register_blueprint(kennzahlen_bp) def health_check(): return "OK" + # für Docker wichtig: host='0.0.0.0' if __name__ == "__main__": socketio.run(app, debug=True, host="0.0.0.0", port=5050) diff --git a/project/backend/coordinator/controller/kpi_setting_controller.py b/project/backend/coordinator/controller/kpi_setting_controller.py index 5a9fb40..94fee96 100644 --- a/project/backend/coordinator/controller/kpi_setting_controller.py +++ b/project/backend/coordinator/controller/kpi_setting_controller.py @@ -34,6 +34,8 @@ def create_kpi_setting(): "type", "translation", "example", + "position", + "active" ] for field in required_fields: if field not in data: @@ -58,6 +60,8 @@ def create_kpi_setting(): type=kpi_type, translation=data["translation"], example=data["example"], + position=data["position"], + active=data["active"] ) db.session.add(new_kpi_setting) @@ -102,6 +106,12 @@ def update_kpi_setting(id): if "example" in data: kpi_setting.example = data["example"] + if "position" in data: + kpi_setting.position = data["position"] + + if "active" in data: + kpi_setting.active = data["active"] + db.session.commit() return jsonify(kpi_setting.to_dict()), 200 @@ -114,3 +124,28 @@ def delete_kpi_setting(id): db.session.commit() return jsonify({"message": f"KPI Setting {id} deleted successfully"}), 200 + + +@kpi_setting_controller.route("/update-kpi-positions", methods=["PUT"]) +def update_kpi_positions(): + data = request.json + + if not data or not isinstance(data, list): + return jsonify({"error": "Expected an array of update objects"}), 400 + + try: + for update_item in data: + if "id" not in update_item or "position" not in update_item: + return jsonify({"error": "Each item must have 'id' and 'position' fields"}), 400 + + kpi_setting = KPISettingModel.query.get_or_404(update_item["id"]) + kpi_setting.position = update_item["position"] + + db.session.commit() + + updated_kpis = KPISettingModel.query.order_by(KPISettingModel.position).all() + return jsonify([kpi.to_dict() for kpi in updated_kpis]), 200 + + except Exception as e: + db.session.rollback() + return jsonify({"error": f"Failed to update positions: {str(e)}"}), 500 \ No newline at end of file diff --git a/project/backend/coordinator/controller/pitch_book_controller.py b/project/backend/coordinator/controller/pitch_book_controller.py index e04e31e..8dc6776 100644 --- a/project/backend/coordinator/controller/pitch_book_controller.py +++ b/project/backend/coordinator/controller/pitch_book_controller.py @@ -13,44 +13,51 @@ from controller.socketIO import socketio pitch_book_controller = Blueprint("pitch_books", __name__, url_prefix="/api/pitch_book") OCR_SERVICE_URL = os.getenv("OCR_SERVICE_URL", "http://localhost:5051") + +progress_per_id = {} # {id: {kpi: 0, pdf: 0}} +storage_lock = threading.Lock() + def process_pdf_async(app, file_id, file_data, filename): with app.app_context(): try: file_obj = BytesIO(file_data) file_obj.name = filename - files = {'file': (filename, file_obj, 'application/pdf')} - data = {'id': file_id} + files = {"file": (filename, file_obj, "application/pdf")} + data = {"id": file_id} response = requests.post( - f"{OCR_SERVICE_URL}/ocr", - files=files, - data=data, - timeout=600 + f"{OCR_SERVICE_URL}/ocr", files=files, data=data, timeout=600 ) if response.status_code == 200: response_data = response.json() - if 'ocr_pdf' in response_data: + if "ocr_pdf" in response_data: import base64 - ocr_pdf_data = base64.b64decode(response_data['ocr_pdf']) + + ocr_pdf_data = base64.b64decode(response_data["ocr_pdf"]) file_record = PitchBookModel.query.get(file_id) if file_record: file_record.file = ocr_pdf_data db.session.commit() - print(f"[DEBUG] PDF updated in database:") - print(f"[DEBUG] - Successfully saved to database") + print("[DEBUG] PDF updated in database:") + print("[DEBUG] - Successfully saved to database") socketio.emit("progress", {"id": file_id, "progress": 50}) else: - socketio.emit("error", {"id": file_id, "message": "OCR processing failed"}) + socketio.emit( + "error", {"id": file_id, "message": "OCR processing failed"} + ) except Exception as e: import traceback + traceback.print_exc() - socketio.emit("error", {"id": file_id, "message": f"Processing failed: {str(e)}"}) + socketio.emit( + "error", {"id": file_id, "message": f"Processing failed: {str(e)}"} + ) @pitch_book_controller.route("/", methods=["POST"]) @@ -78,27 +85,33 @@ def upload_file(): db.session.add(new_file) db.session.commit() - app = current_app._get_current_object() + files = {"file": (uploaded_file.filename, file_data, "application/pdf")} + data = {"id": new_file.id} - socketio.emit("progress", {"id": new_file.id, "progress": 10}) - - processing_thread = threading.Thread( - target=process_pdf_async, - args=(app, new_file.id, file_data, fileName), - daemon=True + response = requests.post( + f"{OCR_SERVICE_URL}/ocr", files=files, data=data, timeout=600 ) - processing_thread.start() + if response.status_code == 200: + socketio.emit("progress", {"id": new_file.id, "progress": 10}) + else: + print("Failed to process file") - return jsonify({ - **new_file.to_dict(), - "status": "processing", - "message": "File uploaded successfully. Processing started." - }), 202 + return ( + jsonify( + { + **new_file.to_dict(), + "status": "processing", + "message": "File uploaded successfully. Processing started.", + } + ), + 202, + ) except Exception as e: print(e) return jsonify({"error": "Invalid file format. Only PDF files are accepted"}), 400 + @pitch_book_controller.route("/", methods=["GET"]) def get_all_files(): files = PitchBookModel.query.all() @@ -128,21 +141,32 @@ def update_file(id): if uploaded_file.filename != "": file.filename = uploaded_file.filename - # Read file data once - file_data = uploaded_file.read() - try: - if ( - uploaded_file - and puremagic.from_string(file_data, mime=True) == "application/pdf" - ): - file.file = file_data - except Exception as e: - print(e) + # Read file data once + file_data = uploaded_file.read() + try: + if ( + file_data + and puremagic.from_string(file_data, mime=True) == "application/pdf" + ): + file.file = file_data + with storage_lock: + if id in progress_per_id and "kpi" in progress_per_id[id]: + del progress_per_id[id]["kpi"] + socketio.emit("progress", {"id": id, "progress": 100}) + else: + progress_per_id[id] = {"pdf": 0} + print(f"[DEBUG] PDF updated in database {id}") + except Exception as e: + print(e) if "kpi" in request.form: - socketio.emit("progress", {"id": id, "progress": 100}) file.kpi = request.form.get("kpi") - + with storage_lock: + if id in progress_per_id and "pdf" in progress_per_id[id]: + del progress_per_id[id]["pdf"] + socketio.emit("progress", {"id": id, "progress": 100}) + else: + progress_per_id[id] = {"kpi": 0} db.session.commit() return jsonify(file.to_dict()), 200 @@ -154,4 +178,4 @@ def delete_file(id): db.session.delete(file) db.session.commit() - return jsonify({"message": f"File {id} deleted successfully"}), 200 \ No newline at end of file + return jsonify({"message": f"File {id} deleted successfully"}), 200 diff --git a/project/backend/coordinator/controller/progress_controller.py b/project/backend/coordinator/controller/progress_controller.py index 90db4df..4d523ce 100644 --- a/project/backend/coordinator/controller/progress_controller.py +++ b/project/backend/coordinator/controller/progress_controller.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify from controller.socketIO import socketio + progress_controller = Blueprint("progress", __name__, url_prefix="/api/progress") @@ -8,10 +9,14 @@ progress_controller = Blueprint("progress", __name__, url_prefix="/api/progress" def progress(): data = request.get_json() - if 'id' not in data or 'progress' not in data: + if "id" not in data or "progress" not in data: return jsonify({"error": "Missing required fields. [id, progress]"}), 400 - if not isinstance(data['progress'], (int, float)) or data['progress'] < 0 or data['progress'] >= 100: + if ( + not isinstance(data["progress"], (int, float)) + or data["progress"] < 0 + or data["progress"] >= 100 + ): return jsonify({"error": "Invalid progress value"}), 400 socketio.emit("progress", {"id": data["id"], "progress": data["progress"]}) diff --git a/project/backend/coordinator/model/database.py b/project/backend/coordinator/model/database.py index 1aec7b3..1d350de 100644 --- a/project/backend/coordinator/model/database.py +++ b/project/backend/coordinator/model/database.py @@ -13,3 +13,5 @@ def init_db(app): db.init_app(app) with app.app_context(): db.create_all() + from model.seed_data import seed_default_kpi_settings + seed_default_kpi_settings() diff --git a/project/backend/coordinator/model/kpi_setting_model.py b/project/backend/coordinator/model/kpi_setting_model.py index f3775a0..52f2de6 100644 --- a/project/backend/coordinator/model/kpi_setting_model.py +++ b/project/backend/coordinator/model/kpi_setting_model.py @@ -10,10 +10,11 @@ class KPISettingType(Enum): RANGE = "range" BOOLEAN = "boolean" ARRAY = "array" + DATE = "date" class KPISettingModel(db.Model): - __tablename__ = 'kpi_setting_model' + __tablename__ = "kpi_setting_model" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(unique=True) @@ -24,6 +25,8 @@ class KPISettingModel(db.Model): ) translation: Mapped[str] example: Mapped[str] + position: Mapped[int] + active: Mapped[bool] def to_dict(self): return { @@ -34,12 +37,16 @@ class KPISettingModel(db.Model): "type": self.type.value, "translation": self.translation, "example": self.example, + "position": self.position, + "active": self.active } - def __init__(self, name, description, mandatory, type, translation, example): + def __init__(self, name, description, mandatory, type, translation, example, position, active): self.name = name self.description = description self.mandatory = mandatory self.type = type self.translation = translation self.example = example + self.position = position + self.active = active diff --git a/project/backend/coordinator/model/seed_data.py b/project/backend/coordinator/model/seed_data.py new file mode 100644 index 0000000..e13145b --- /dev/null +++ b/project/backend/coordinator/model/seed_data.py @@ -0,0 +1,184 @@ +from model.database import db +from model.kpi_setting_model import KPISettingModel, KPISettingType + +def seed_default_kpi_settings(): + if KPISettingModel.query.first() is not None: + print("KPI Settings bereits vorhanden, Seeding übersprungen") + return + + default_kpi_settings = [ + { + "name": "Fondsname", + "description": "Der vollständige Name des Investmentfonds", + "mandatory": True, + "type": KPISettingType.STRING, + "translation": "Fund Name", + "example": "Alpha Real Estate Fund I", + "position": 1, + "active": True + }, + { + "name": "Fondsmanager", + "description": "Verantwortlicher Manager für die Fondsverwaltung", + "mandatory": True, + "type": KPISettingType.STRING, + "translation": "Fund Manager", + "example": "Max Mustermann", + "position": 2, + "active": True + }, + { + "name": "AIFM", + "description": "Alternative Investment Fund Manager", + "mandatory": True, + "type": KPISettingType.STRING, + "translation": "AIFM", + "example": "Alpha Investment Management GmbH", + "position": 3, + "active": True + }, + { + "name": "Datum", + "description": "Stichtag der Datenerfassung", + "mandatory": True, + "type": KPISettingType.DATE, + "translation": "Date", + "example": "05.05.2025", + "position": 4, + "active": True + }, + { + "name": "Risikoprofil", + "description": "Klassifizierung des Risikos des Fonds", + "mandatory": True, + "type": KPISettingType.STRING, + "translation": "Risk Profile", + "example": "Core/Core++", + "position": 5, + "active": True + }, + { + "name": "Artikel", + "description": "Artikel 8 SFDR-Klassifizierung", + "mandatory": False, + "type": KPISettingType.BOOLEAN, + "translation": "Article", + "example": "Artikel 8", + "position": 6, + "active": True + }, + { + "name": "Zielrendite", + "description": "Angestrebte jährliche Rendite in Prozent", + "mandatory": True, + "type": KPISettingType.NUMBER, + "translation": "Target Return", + "example": "6.5", + "position": 7, + "active": True + }, + { + "name": "Rendite", + "description": "Tatsächlich erzielte Rendite in Prozent", + "mandatory": False, + "type": KPISettingType.NUMBER, + "translation": "Return", + "example": "5.8", + "position": 8, + "active": True + }, + { + "name": "Zielausschüttung", + "description": "Geplante Ausschüttung in Prozent", + "mandatory": False, + "type": KPISettingType.NUMBER, + "translation": "Target Distribution", + "example": "4.0", + "position": 9, + "active": True + }, + { + "name": "Ausschüttung", + "description": "Tatsächliche Ausschüttung in Prozent", + "mandatory": False, + "type": KPISettingType.NUMBER, + "translation": "Distribution", + "example": "3.8", + "position": 10, + "active": True + }, + { + "name": "Laufzeit", + "description": "Geplante Laufzeit des Fonds", + "mandatory": True, + "type": KPISettingType.STRING, + "translation": "Duration", + "example": "7 Jahre, 10, Evergreen", + "position": 11, + "active": True + }, + { + "name": "LTV", + "description": "Loan-to-Value Verhältnis in Prozent", + "mandatory": False, + "type": KPISettingType.NUMBER, + "translation": "LTV", + "example": "65.0", + "position": 12, + "active": True + }, + { + "name": "Managementgebühren", + "description": "Jährliche Verwaltungsgebühren in Prozent", + "mandatory": True, + "type": KPISettingType.NUMBER, + "translation": "Management Fees", + "example": "1.5", + "position": 13, + "active": True + }, + { + "name": "Sektorenallokation", + "description": "Verteilung der Investments nach Sektoren", + "mandatory": False, + "type": KPISettingType.ARRAY, + "translation": "Sector Allocation", + "example": "Büro, Wohnen, Logistik, Studentenwohnen", + "position": 14, + "active": True + }, + { + "name": "Länderallokation", + "description": "Geografische Verteilung der Investments", + "mandatory": False, + "type": KPISettingType.ARRAY, + "translation": "Country Allocation", + "example": "Deutschland,Frankreich, Österreich, Schweiz", + "position": 15, + "active": True + } + ] + + print("Füge Standard KPI Settings hinzu...") + + for kpi_data in default_kpi_settings: + kpi_setting = KPISettingModel( + name=kpi_data["name"], + description=kpi_data["description"], + mandatory=kpi_data["mandatory"], + type=kpi_data["type"], + translation=kpi_data["translation"], + example=kpi_data["example"], + position=kpi_data["position"], + active=kpi_data["active"] + ) + + db.session.add(kpi_setting) + + try: + db.session.commit() + print(f"Erfolgreich {len(default_kpi_settings)} Standard KPI Settings hinzugefügt") + except Exception as e: + db.session.rollback() + print(f"Fehler beim Hinzufügen der Standard KPI Settings: {e}") + raise \ No newline at end of file diff --git a/project/backend/ocr-service/.dockerignore b/project/backend/ocr-service/.dockerignore new file mode 100644 index 0000000..dd498c3 --- /dev/null +++ b/project/backend/ocr-service/.dockerignore @@ -0,0 +1,3 @@ +venv +.venv +__pycache__ diff --git a/project/backend/ocr-service/app.py b/project/backend/ocr-service/app.py index 96bc4f8..06a6f14 100644 --- a/project/backend/ocr-service/app.py +++ b/project/backend/ocr-service/app.py @@ -1,11 +1,12 @@ -from flask import Flask, request, jsonify +from flask import Flask, request from ocr_runner import pdf_to_json, ocr_pdf import requests import os import tempfile -import base64 from pathlib import Path import logging +import threading + # Set up logging logging.basicConfig(level=logging.INFO) @@ -15,6 +16,61 @@ app = Flask(__name__) EXXETA_URL = os.getenv("EXXETA_SERVICE_URL", "http://localhost:5053/extract") SPACY_URL = os.getenv("SPACY_SERVICE_URL", "http://localhost:5052/extract") +COORDINATOR_URL = os.getenv("COORDINATOR_URL", "http://localhost:5050") + + +def convert_pdf_async(temp_path, pitchbook_id): + try: + logger.info("Starting OCR process...") + + ocr_path = ocr_pdf(temp_path) + + if not ocr_path or not ocr_path.exists(): + temp_path.unlink() # cleanup + return {"error": "OCR processing failed - all PDFs must be OCR'd"}, 500 + + with open(ocr_path, 'rb') as ocr_file: + ocr_file.seek(0) + result = pdf_to_json(ocr_file) + + + payload = { + "id": int(pitchbook_id), + "extracted_text_per_page": result["pages"] + } + + logger.info("Sending payload to EXXETA and SPACY services") + + try: + exxeta_response = requests.post(EXXETA_URL, json=payload, timeout=600) + logger.info(f"EXXETA response: {exxeta_response.status_code}") + except Exception as e: + logger.error(f"Error calling EXXETA: {e}") + + try: + spacy_response = requests.post(SPACY_URL, json=payload, timeout=600) + logger.info(f"SPACY response: {spacy_response.status_code}") + except Exception as e: + logger.error(f"Error calling SPACY: {e}") + + files=[ + ('file',('',open(ocr_path,'rb'),'application/pdf')) + ] + headers = {} + + try: + requests.put(f"{COORDINATOR_URL}/api/pitch_book/{pitchbook_id}", files=files, timeout=600, headers=headers) + + requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 50}, timeout=600) + logger.info("COORDINATOR response: Progress + File updated") + except Exception as e: + logger.error(f"Error calling COORDINATOR: {e}") + + ocr_path.unlink() + temp_path.unlink() + except Exception as e: + logger.error(f"Exception in OCR processing: {str(e)}", exc_info=True) + @app.route('/ocr', methods=['POST']) def convert_extract_text_from_pdf(): @@ -29,59 +85,20 @@ def convert_extract_text_from_pdf(): if not pitchbook_id: return {"error": "No ID"}, 400 - try: - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: - file.seek(0) - temp_file.write(file.read()) - temp_path = Path(temp_file.name) + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + file.seek(0) + temp_file.write(file.read()) + temp_path = Path(temp_file.name) - logger.info("Starting OCR process...") + thread = threading.Thread(target=convert_pdf_async, args=(temp_path, pitchbook_id)) + thread.start() - ocr_path = ocr_pdf(temp_path) + return { + "status": "sent", + "message": "PDF successfully OCR'd and processed" + }, 200 - if not ocr_path or not ocr_path.exists(): - temp_path.unlink() # cleanup - return {"error": "OCR processing failed - all PDFs must be OCR'd"}, 500 - - with open(ocr_path, 'rb') as ocr_file: - ocr_pdf_data = ocr_file.read() - ocr_pdf_base64 = base64.b64encode(ocr_pdf_data).decode('utf-8') - - ocr_file.seek(0) - result = pdf_to_json(ocr_file) - - ocr_path.unlink() - temp_path.unlink() - - payload = { - "id": int(pitchbook_id), - "extracted_text_per_page": result["pages"] - } - - logger.info(f"Sending payload to EXXETA and SPACY services") - - try: - exxeta_response = requests.post(EXXETA_URL, json=payload, timeout=600) - logger.info(f"EXXETA response: {exxeta_response.status_code}") - except Exception as e: - logger.error(f"Error calling EXXETA: {e}") - - try: - spacy_response = requests.post(SPACY_URL, json=payload, timeout=600) - logger.info(f"SPACY response: {spacy_response.status_code}") - except Exception as e: - logger.error(f"Error calling SPACY: {e}") - - return { - "status": "sent", - "ocr_pdf": ocr_pdf_base64, - "message": "PDF successfully OCR'd and processed" - }, 200 - - except Exception as e: - logger.error(f"Exception in OCR processing: {str(e)}", exc_info=True) - return {"error": f"Processing failed: {str(e)}"}, 500 if __name__ == "__main__": logger.info("Starting OCR service on port 5000") - app.run(host="0.0.0.0", port=5000, debug=True) \ No newline at end of file + app.run(host="0.0.0.0", port=5051, debug=True) diff --git a/project/backend/spacy-service/Dockerfile b/project/backend/spacy-service/Dockerfile index 6b5dcde..d7ad008 100644 --- a/project/backend/spacy-service/Dockerfile +++ b/project/backend/spacy-service/Dockerfile @@ -16,4 +16,6 @@ RUN python -m spacy download en_core_web_sm COPY .. /app +ENV PYTHONUNBUFFERED=1 + CMD ["python3.12", "app.py"] diff --git a/project/backend/validate-service/.dockerignore b/project/backend/validate-service/.dockerignore new file mode 100644 index 0000000..c07f7de --- /dev/null +++ b/project/backend/validate-service/.dockerignore @@ -0,0 +1,3 @@ +.venv +venv +__pycache__ diff --git a/project/backend/validate-service/app.py b/project/backend/validate-service/app.py index 2c821d5..2dfb9cb 100644 --- a/project/backend/validate-service/app.py +++ b/project/backend/validate-service/app.py @@ -10,7 +10,7 @@ import json app = Flask(__name__) load_dotenv() -coordinator_url = os.getenv("COORDINATOR_URL", "localhost:5000") +coordinator_url = os.getenv("COORDINATOR_URL", "http://localhost:5000") # todo add persistence layer data_storage = {} # {id: {spacy_data: [], exxeta_data: []}} @@ -28,7 +28,7 @@ def send_to_coordinator_service(processed_data, request_id): "kpi": json.dumps(processed_data), } requests.put( - "http://" + coordinator_url + "/api/pitch_book/" + str(request_id), + coordinator_url + "/api/pitch_book/" + str(request_id), data=payload, ) print(f"Result PitchBook {request_id} sent to coordinator") @@ -128,4 +128,4 @@ def validate(): if __name__ == "__main__": - app.run(debug=True, host="0.0.0.0", port=5054) \ No newline at end of file + app.run(debug=True, host="0.0.0.0", port=5054) diff --git a/project/docker-compose.yml b/project/docker-compose.yml index cc22d06..3cfe028 100644 --- a/project/docker-compose.yml +++ b/project/docker-compose.yml @@ -73,6 +73,6 @@ services: env_file: - .env environment: - - COORDINATOR_URL=coordinator:5000 + - COORDINATOR_URL=http://coordinator:5000 ports: - - 5054:5000 \ No newline at end of file + - 5054:5000 diff --git a/project/frontend/src/components/ConfigTable.tsx b/project/frontend/src/components/ConfigTable.tsx new file mode 100644 index 0000000..017d113 --- /dev/null +++ b/project/frontend/src/components/ConfigTable.tsx @@ -0,0 +1,322 @@ +import { Box, Tooltip, CircularProgress, Typography } from "@mui/material"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import type { Kennzahl } from "../types/kpi"; +import { getDisplayType } from "../types/kpi"; + +export function ConfigTable() { + const navigate = useNavigate(); + const [kennzahlen, setKennzahlen] = useState([]); + const [draggedItem, setDraggedItem] = useState(null); + const [isUpdatingPositions, setIsUpdatingPositions] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchKennzahlen = async () => { + while (true) { + try { + console.log('Fetching kennzahlen from API...'); + const response = await fetch(`http://localhost:5050/api/kpi_setting/`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + console.log('Fetched kennzahlen:', data); + const sortedData = data.sort((a: Kennzahl, b: Kennzahl) => a.position - b.position); + setKennzahlen(sortedData); + setLoading(false); + break; + } catch (err) { + console.error('Error fetching kennzahlen:', err); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + }; + + fetchKennzahlen(); + }, []); + + const handleToggleActive = async (id: number) => { + const kennzahl = kennzahlen.find(k => k.id === id); + if (!kennzahl) return; + + try { + const response = await fetch(`http://localhost:5050/api/kpi_setting/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + active: !kennzahl.active + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const updatedKennzahl = await response.json(); + setKennzahlen(prev => + prev.map(item => + item.id === id ? updatedKennzahl : item + ) + ); + } catch (err) { + console.error('Error toggling active status:', err); + setKennzahlen(prev => + prev.map(item => + item.id === id ? kennzahl : item + ) + ); + } + }; + + const updatePositionsInBackend = async (reorderedKennzahlen: Kennzahl[]) => { + setIsUpdatingPositions(true); + try { + const positionUpdates = reorderedKennzahlen.map((kennzahl, index) => ({ + id: kennzahl.id, + position: index + 1 + })); + + const response = await fetch(`http://localhost:5050/api/kpi_setting/update-kpi-positions`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(positionUpdates), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const updatedKennzahlen = await response.json(); + setKennzahlen(updatedKennzahlen); + } catch (err) { + console.error('Error updating positions:', err); + window.location.reload(); + } finally { + setIsUpdatingPositions(false); + } + }; + + const handleDragStart = (e: React.DragEvent, item: Kennzahl) => { + setDraggedItem(item); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDrop = async (e: React.DragEvent, targetItem: Kennzahl) => { + e.preventDefault(); + if (!draggedItem || draggedItem.id === targetItem.id) return; + + const draggedIndex = kennzahlen.findIndex(item => item.id === draggedItem.id); + const targetIndex = kennzahlen.findIndex(item => item.id === targetItem.id); + + const newKennzahlen = [...kennzahlen]; + const [removed] = newKennzahlen.splice(draggedIndex, 1); + newKennzahlen.splice(targetIndex, 0, removed); + + setKennzahlen(newKennzahlen); + setDraggedItem(null); + await updatePositionsInBackend(newKennzahlen); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + }; + + const handleRowClick = (kennzahl: Kennzahl, e: React.MouseEvent) => { + if (draggedItem || isUpdatingPositions) { + return; + } + + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') { + return; + } + + if (target.closest('.drag-handle')) { + return; + } + + console.log('Navigating to detail page for KPI:', kennzahl); + console.log('KPI ID:', kennzahl.id); + + navigate({ + to: `/config-detail/$kpiId`, + params: { kpiId: kennzahl.id.toString() } + }); + }; + + if (loading) { + return ( + + + Lade Kennzahlen-Konfiguration... + + ); + } + + return ( + + + + + + + + + + + + {kennzahlen.map((kennzahl) => ( + handleDragStart(e, kennzahl)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, kennzahl)} + onDragEnd={handleDragEnd} + onClick={(e) => handleRowClick(kennzahl, e)} + style={{ + borderBottom: "1px solid #e0e0e0", + cursor: isUpdatingPositions ? "default" : "pointer", + backgroundColor: draggedItem?.id === kennzahl.id ? "#f0f0f0" : "white", + opacity: draggedItem?.id === kennzahl.id ? 0.5 : 1 + }} + onMouseEnter={(e) => { + if (!draggedItem && !isUpdatingPositions) { + e.currentTarget.style.backgroundColor = "#f9f9f9"; + } + }} + onMouseLeave={(e) => { + if (!draggedItem && !isUpdatingPositions) { + e.currentTarget.style.backgroundColor = "white"; + } + }} + > + + + + + + ))} + +
+ + Aktiv + + Name + + Format +
+
+ + Neuanordnung der Kennzahlen
+ Hier können Sie die Kennzahlen nach Belieben per Drag and Drop neu anordnen. + + } + placement="left" + arrow + > + +
+
+
+ handleToggleActive(kennzahl.id)} + disabled={isUpdatingPositions} + style={{ + width: "18px", + height: "18px", + cursor: isUpdatingPositions ? "default" : "pointer", + accentColor: "#383838" + }} + onClick={(e) => e.stopPropagation()} + /> + + + {kennzahl.name} + + + + {getDisplayType(kennzahl.type)} + +
+
+ ); +} \ No newline at end of file diff --git a/project/frontend/src/components/KPIForm.tsx b/project/frontend/src/components/KPIForm.tsx new file mode 100644 index 0000000..9fcef74 --- /dev/null +++ b/project/frontend/src/components/KPIForm.tsx @@ -0,0 +1,247 @@ +import { Box, Typography, Button, Paper, TextField, FormControlLabel, + Checkbox, Select, MenuItem, FormControl, InputLabel, Divider, CircularProgress } from "@mui/material"; +import { useState, useEffect } from "react"; +import type { Kennzahl } from "../types/kpi"; +import { typeDisplayMapping } from "../types/kpi"; + +interface KPIFormProps { + mode: 'add' | 'edit'; + initialData?: Kennzahl | null; + onSave: (data: Partial) => Promise; + onCancel: () => void; + loading?: boolean; +} + +const emptyKPI: Partial = { + name: '', + description: '', + mandatory: false, + type: 'string', + translation: '', + example: '', + active: true +}; + +export function KPIForm({ mode, initialData, onSave, onCancel, loading = false }: KPIFormProps) { + const [formData, setFormData] = useState>(emptyKPI); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (mode === 'edit' && initialData) { + setFormData(initialData); + } else { + setFormData(emptyKPI); + } + }, [mode, initialData]); + + const handleSave = async () => { + if (!formData.name?.trim()) { + alert('Name ist erforderlich'); + return; + } + + setIsSaving(true); + try { + await onSave(formData); + } catch (error) { + console.error('Error saving KPI:', error); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + onCancel(); + }; + + const updateField = (field: keyof Kennzahl, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + if (loading) { + return ( + + + + {mode === 'edit' ? 'Lade KPI Details...' : 'Laden...'} + + + ); + } + + return ( + + + + Kennzahl + + updateField('name', e.target.value)} + sx={{ mb: 2 }} + required + error={!formData.name?.trim()} + helperText={!formData.name?.trim() ? 'Name ist erforderlich' : ''} + /> + + + + + + + Beschreibung + + updateField('description', e.target.value)} + helperText="Beschreibung der Kennzahl" + /> + + + updateField('mandatory', e.target.checked)} + sx={{ color: '#383838' }} + /> + } + label="Erforderlich" + /> + + Die Kennzahl erlaubt keine leeren Werte + + + + + + + + + Format: {typeDisplayMapping[formData.type as keyof typeof typeDisplayMapping] || formData.type} + + + Typ + + + + + + + + + Synonyme & Übersetzungen + + updateField('translation', e.target.value)} + helperText="z.B. Englische Übersetzung der Kennzahl" + /> + + + + + + + Beispiele von Kennzahl + + updateField('example', e.target.value)} + helperText="Beispielwerte für diese Kennzahl" + /> + + + {mode === 'add' && ( + <> + + + updateField('active', e.target.checked)} + sx={{ color: '#383838' }} + /> + } + label="Aktiv" + /> + + Die Kennzahl ist aktiv und wird angezeigt + + + + )} + + + + + + + ); +} \ No newline at end of file diff --git a/project/frontend/src/routes/config-add.tsx b/project/frontend/src/routes/config-add.tsx new file mode 100644 index 0000000..b90d7b1 --- /dev/null +++ b/project/frontend/src/routes/config-add.tsx @@ -0,0 +1,87 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Box, Typography, IconButton } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { KPIForm } from "../components/KPIForm"; +import type { Kennzahl } from "../types/kpi"; + +export const Route = createFileRoute("/config-add")({ + component: ConfigAddPage, +}); + +function ConfigAddPage() { + const navigate = useNavigate(); + + const handleSave = async (formData: Partial) => { + try { + const existingKPIsResponse = await fetch('http://localhost:5050/api/kpi_setting/'); + const existingKPIs = await existingKPIsResponse.json(); + const maxPosition = existingKPIs.length > 0 + ? Math.max(...existingKPIs.map((kpi: Kennzahl) => kpi.position)) + : 0; + + const kpiData = { + ...formData, + position: maxPosition + 1, + active: formData.active !== false + }; + + const response = await fetch('http://localhost:5050/api/kpi_setting/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(kpiData), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + navigate({ to: "/config" }); + } catch (error) { + console.error('Error creating KPI:', error); + throw error; + } + }; + + const handleCancel = () => { + navigate({ to: "/config" }); + }; + + return ( + + + + navigate({ to: "/config" })}> + + + + Neue Kennzahl hinzufügen + + + + + + + ); +} \ No newline at end of file diff --git a/project/frontend/src/routes/config-detail.$kpiId.tsx b/project/frontend/src/routes/config-detail.$kpiId.tsx new file mode 100644 index 0000000..16ddce9 --- /dev/null +++ b/project/frontend/src/routes/config-detail.$kpiId.tsx @@ -0,0 +1,269 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Box, Typography, IconButton, Button, CircularProgress, Paper, Divider +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { useEffect, useState } from "react"; +import type { Kennzahl } from "../types/kpi"; +import { KPIForm } from "../components/KPIForm"; +import { typeDisplayMapping } from "../types/kpi"; + +export const Route = createFileRoute("/config-detail/$kpiId")({ + component: KPIDetailPage, +}); + +function KPIDetailPage() { + const { kpiId } = Route.useParams(); + const navigate = useNavigate(); + const [kennzahl, setKennzahl] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchKennzahl = async () => { + try { + setLoading(true); + const response = await fetch(`http://localhost:5050/api/kpi_setting/${kpiId}`); + if (!response.ok) { + if (response.status === 404) { + setError('KPI not found'); + return; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + setKennzahl(data); + setError(null); + } catch (err) { + console.error('Error fetching KPI:', err); + setError('Error loading KPI'); + } finally { + setLoading(false); + } + }; + + fetchKennzahl(); + }, [kpiId]); + + const handleSave = async (formData: Partial) => { + try { + const response = await fetch(`http://localhost:5050/api/kpi_setting/${kpiId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const updatedKennzahl = await response.json(); + setKennzahl(updatedKennzahl); + setIsEditing(false); + } catch (error) { + console.error('Error saving KPI:', error); + throw error; + } + }; + + const handleCancel = () => { + setIsEditing(false); + }; + + if (loading) { + return ( + + + Lade KPI Details... + + ); + } + + if (error || !kennzahl) { + return ( + + + {error || 'KPI nicht gefunden'} + + + + ); + } + + if (!isEditing) { + return ( + + + + navigate({ to: "/config" })}> + + + + Detailansicht + + + + + + + + Kennzahl + + + {kennzahl.name} + + + + + + + + Beschreibung + + + {kennzahl.description || "Zurzeit ist die Beschreibung der Kennzahl leer. Klicken Sie auf den Bearbeiten-Button, um die Beschreibung zu ergänzen."} + + + + + Erforderlich: {kennzahl.mandatory ? 'Ja' : 'Nein'} + + + + + + + + + Format + + + {typeDisplayMapping[kennzahl.type] || kennzahl.type} + + + + + + + + Synonyme & Übersetzungen + + + {kennzahl.translation || "Zurzeit gibt es keine Einträge für Synonyme und Übersetzungen der Kennzahl. Klicken Sie auf den Bearbeiten-Button, um die Liste zu ergänzen."} + + + + + + + + Beispiele von Kennzahl + + + {kennzahl.example || "Zurzeit gibt es keine Beispiele der Kennzahl. Klicken Sie auf den Bearbeiten-Button, um die Liste zu ergänzen."} + + + + + ); + } + + return ( + + + + navigate({ to: "/config" })}> + + + + Kennzahl bearbeiten + + + + + + + ); +} \ No newline at end of file diff --git a/project/frontend/src/routes/config.tsx b/project/frontend/src/routes/config.tsx index 5ddecdb..eddf16e 100644 --- a/project/frontend/src/routes/config.tsx +++ b/project/frontend/src/routes/config.tsx @@ -1,7 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Box, Button, IconButton, Paper, Typography } from "@mui/material"; +import { Box, Button, IconButton, Typography } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useNavigate } from "@tanstack/react-router"; +import { ConfigTable } from "../components/ConfigTable"; export const Route = createFileRoute("/config")({ component: ConfigPage, @@ -10,15 +11,20 @@ export const Route = createFileRoute("/config")({ function ConfigPage() { const navigate = useNavigate(); + const handleAddNewKPI = () => { + navigate({ to: "/config-add" }); + }; + return (