#19-Konfiguration-anzeigen #55

Merged
3025495 merged 6 commits from #19-Konfiguration-anzeigen into main 2025-06-09 11:08:35 +02:00
12 changed files with 1256 additions and 22 deletions

View File

@ -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

View File

@ -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()

View File

@ -10,6 +10,7 @@ class KPISettingType(Enum):
RANGE = "range"
BOOLEAN = "boolean"
ARRAY = "array"
DATE = "date"
class KPISettingModel(db.Model):
@ -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

View File

@ -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

View File

@ -16,4 +16,6 @@ RUN python -m spacy download en_core_web_sm
COPY .. /app
ENV PYTHONUNBUFFERED=1
CMD ["python3.12", "app.py"]

View File

@ -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<Kennzahl[]>([]);
const [draggedItem, setDraggedItem] = useState<Kennzahl | null>(null);
const [isUpdatingPositions, setIsUpdatingPositions] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchKennzahlen = async () => {
while (true) {

Warum ist hier das while(true){}?

Warum ist hier das while(true){}?

Das while (true) ist für eine "einfache" Retry-Logik:
Falls der API-Call beim ersten Mal fehlschlägt (z. B. weil der Backend-Service oder die Datenbank gerade erst gestartet wird oder temporär nicht verfügbar ist), wird der Fetch alle 2 Sekunden wiederholt, bis es erfolgreich ist. Dadurch muss der User die Seite nicht manuell neu laden, und es entstehen keine Fehler im Frontend, wenn z. B. der Coordinator oder die Datenbank neu startet oder gerade noch nicht bereit ist.

Das while (true) ist für eine "einfache" Retry-Logik: Falls der API-Call beim ersten Mal fehlschlägt (z. B. weil der Backend-Service oder die Datenbank gerade erst gestartet wird oder temporär nicht verfügbar ist), wird der Fetch alle 2 Sekunden wiederholt, bis es erfolgreich ist. Dadurch muss der User die Seite nicht manuell neu laden, und es entstehen keine Fehler im Frontend, wenn z. B. der Coordinator oder die Datenbank neu startet oder gerade noch nicht bereit ist.
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();
3019483 marked this conversation as resolved

Du updatest hier den state von der "active - checkbox" im frontend erst, wenn du das response vom Server bekommst. Wenn alles auf deinem Rechner läuft funktioniert das noch gut, aber sobald frontend und backend nicht so schnell kommunizieren fühlt sich das sehr schnell laggy an (kannst du mit simuliertem langsamen Internet testen. Das fällt schon bei 4G auf).
Was man hier macht heißt "optimistic update", vereinfacht zusammengefasst: man updated die ui im frontend direkt und nur wenn der request failed rollt man das update zurück.
Wenn dich das interessiert, zu der Umsetzung steht mehr in dem pdf. Dazu ein bisschen Hintergrund: Da geht es um die Bibliothek "Tanstack Query" (die kann nicht nur react), die kümmert sich mittels caching darum, einen asyncronen state zu verwalten (also den Server-State im Frontend). Hier eine kurze erklärung zu react-query: https://ui.dev/c/query/why-react-query (wenn dich das weiter interessiert). Für die Begriffserklärung in dem PDF: Die Bibliothek unterstützt mehrere Funktionen:

  • useQuery: für das einfache laden von daten
  • useMutation: für das ändern von daten (das sind die mutations von denen da die rede ist)

Diese Liste, die häufiger in den Code-Beispielen vorkommt: ['todos', 'list', { sort }], steht immer für den key, unter dem das Element im cache abgelegt ist.

Du updatest hier den state von der "active - checkbox" im frontend erst, wenn du das response vom Server bekommst. Wenn alles auf deinem Rechner läuft funktioniert das noch gut, aber sobald frontend und backend nicht so schnell kommunizieren fühlt sich das sehr schnell laggy an (kannst du mit simuliertem langsamen Internet testen. Das fällt schon bei 4G auf). Was man hier macht heißt "optimistic update", vereinfacht zusammengefasst: man updated die ui im frontend direkt und nur wenn der request failed rollt man das update zurück. Wenn dich das interessiert, zu der Umsetzung steht mehr in dem pdf. Dazu ein bisschen Hintergrund: Da geht es um die Bibliothek "[Tanstack Query](https://tanstack.com/query/v4/docs/framework/react/overview)" (die kann nicht nur react), die kümmert sich mittels caching darum, einen asyncronen state zu verwalten (also den Server-State im Frontend). Hier eine kurze erklärung zu react-query: [https://ui.dev/c/query/why-react-query](https://ui.dev/c/query/why-react-query) (wenn dich das weiter interessiert). Für die Begriffserklärung in dem PDF: Die Bibliothek unterstützt mehrere Funktionen: - useQuery: für das einfache laden von daten - useMutation: für das ändern von daten (das sind die mutations von denen da die rede ist) Diese Liste, die häufiger in den Code-Beispielen vorkommt: ```['todos', 'list', { sort }]```, steht immer für den key, unter dem das Element im cache abgelegt ist.
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<HTMLTableRowElement>, item: Kennzahl) => {
setDraggedItem(item);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent<HTMLTableRowElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = async (e: React.DragEvent<HTMLTableRowElement>, 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 (
<Box
height="100vh"
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
mt={8}
>
<CircularProgress sx={{ color: '#383838', mb: 2 }} />
<Typography>Lade Kennzahlen-Konfiguration...</Typography>
</Box>
);
}
return (
<Box
sx={{
width: "70%",
maxWidth: 800,
borderRadius: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
backgroundColor: "white",
overflow: "hidden",
opacity: isUpdatingPositions ? 0.7 : 1,
pointerEvents: isUpdatingPositions ? 'none' : 'auto'
}}
>
<table style={{
width: "100%",
borderCollapse: "collapse"
}}>
<thead>
<tr style={{ backgroundColor: "#f5f5f5" }}>
<th style={{
padding: "16px 12px",
textAlign: "left",
fontWeight: "bold",
width: "60px",
borderBottom: "1px solid #e0e0e0"
}}>
</th>
<th style={{
padding: "16px 12px",
textAlign: "left",
fontWeight: "bold",
width: "80px",
borderBottom: "1px solid #e0e0e0"
}}>
Aktiv
</th>
<th style={{
padding: "16px 12px",
textAlign: "left",
fontWeight: "bold",
borderBottom: "1px solid #e0e0e0"
}}>
Name
</th>
<th style={{
padding: "16px 12px",
textAlign: "left",
fontWeight: "bold",
width: "160px",
borderBottom: "1px solid #e0e0e0"
}}>
Format
</th>
</tr>
</thead>
<tbody>
{kennzahlen.map((kennzahl) => (
<tr
key={kennzahl.id}
draggable={!isUpdatingPositions}
onDragStart={(e) => 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";
}
}}
>
<td style={{ padding: "12px", textAlign: "center" }}>
<div className="drag-handle">
<Tooltip
title={
<>
<b>Neuanordnung der Kennzahlen</b><br />
Hier können Sie die Kennzahlen nach Belieben per Drag and Drop neu anordnen.
</>
}
placement="left"
arrow
>
<DragIndicatorIcon
sx={{
color: isUpdatingPositions ? "#ccc" : "#999",
cursor: isUpdatingPositions ? "default" : "grab",
"&:active": { cursor: isUpdatingPositions ? "default" : "grabbing" }
}}
/>
</Tooltip>
</div>
</td>
<td style={{ padding: "12px" }}>
<input
type="checkbox"
checked={kennzahl.active}
onChange={() => handleToggleActive(kennzahl.id)}
disabled={isUpdatingPositions}
style={{
width: "18px",
height: "18px",
cursor: isUpdatingPositions ? "default" : "pointer",
accentColor: "#383838"
}}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td style={{
padding: "12px",
fontSize: "14px",
color: "#333"
}}>
<span title={`Click to view details (ID: ${kennzahl.id})`}>
{kennzahl.name}
</span>
</td>
<td style={{ padding: "12px" }}>
<span style={{
color: "#333",
padding: "4px 12px",
borderRadius: "16px",
fontSize: "12px",
fontWeight: "500",
border: "1px solid #ddd",
backgroundColor: "#f8f9fa"
}}>
{getDisplayType(kennzahl.type)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Box>
);
}

View File

@ -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<Kennzahl>) => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
const emptyKPI: Partial<Kennzahl> = {
name: '',
description: '',
mandatory: false,
type: 'string',
translation: '',
example: '',
active: true
};
export function KPIForm({ mode, initialData, onSave, onCancel, loading = false }: KPIFormProps) {
const [formData, setFormData] = useState<Partial<Kennzahl>>(emptyKPI);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {

Du kannst dieses/n (keine ahnung ob das "der", "die" oder "das" hook ist) hook sparen, wenn du die initalData mit den Standard-Daten von emptyKPI vorbelegst:

interface KPIFormProps {
	mode: "add" | "edit";
	initialData?: Partial<Kennzahl>; //hier das null weg und das partial hinzu
	onSave: (data: Partial<Kennzahl>) => Promise<void>;
	onCancel: () => void;
	loading?: boolean;
}

const emptyKPI: Partial<Kennzahl> = {
	name: "",
	description: "",
	mandatory: false,
	type: "string",
	translation: "",
	example: "",
	active: true,
};

export function KPIForm({
	mode,
	initialData = emptyKPI, // und hier den wert setzen
	onSave,
	onCancel,
	loading = false,
}: KPIFormProps) {
	const [formData, setFormData] = useState<Partial<Kennzahl>>(initialData);
Du kannst dieses/n (keine ahnung ob das "der", "die" oder "das" hook ist) hook sparen, wenn du die initalData mit den Standard-Daten von emptyKPI vorbelegst: ```typescript interface KPIFormProps { mode: "add" | "edit"; initialData?: Partial<Kennzahl>; //hier das null weg und das partial hinzu onSave: (data: Partial<Kennzahl>) => Promise<void>; onCancel: () => void; loading?: boolean; } const emptyKPI: Partial<Kennzahl> = { name: "", description: "", mandatory: false, type: "string", translation: "", example: "", active: true, }; export function KPIForm({ mode, initialData = emptyKPI, // und hier den wert setzen onSave, onCancel, loading = false, }: KPIFormProps) { const [formData, setFormData] = useState<Partial<Kennzahl>>(initialData); ```

Ich kenne es so, dass man für die Wiederverwendbarkeit der Komponente den useEffect benötigt. Wenn man zwischen den unterschiedlichen Modi (add/edit) wechselt oder im Edit-Modus zwischen verschiedenen KPIs zum Bearbeiten wechselt, verhindert der useEffect Fehler bei der initialen Datensetzung. Ohne den useEffect könnten z.B. alte Daten von einer KPI noch sichtbar bleiben, weil die neuen Daten nicht richtig "überschrieben" werden.

Wahrscheinlich ist es für unser Projekt eh nicht so wichtig. Oder ist es bei React ein bad practice/Soll ich es trotzdem ändern?

Ich kenne es so, dass man für die Wiederverwendbarkeit der Komponente den useEffect benötigt. Wenn man zwischen den unterschiedlichen Modi (add/edit) wechselt oder im Edit-Modus zwischen verschiedenen KPIs zum Bearbeiten wechselt, verhindert der useEffect Fehler bei der initialen Datensetzung. Ohne den useEffect könnten z.B. alte Daten von einer KPI noch sichtbar bleiben, weil die neuen Daten nicht richtig "überschrieben" werden. Wahrscheinlich ist es für unser Projekt eh nicht so wichtig. Oder ist es bei React ein bad practice/Soll ich es trotzdem ändern?
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 = () => {
3019483 marked this conversation as resolved

ich glaube, die funktion brauchst du gar nicht, da der state eh verloren geht durch das onCancel()

ich glaube, die funktion brauchst du gar nicht, da der state eh verloren geht durch das onCancel()
onCancel();
};
const updateField = (field: keyof Kennzahl, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="400px"
flexDirection="column"
>
<CircularProgress sx={{ color: '#383838', mb: 2 }} />
<Typography>
{mode === 'edit' ? 'Lade KPI Details...' : 'Laden...'}
</Typography>
</Box>
);
}
return (
<Paper
elevation={2}
sx={{
width: "90%",
maxWidth: 800,
p: 4,
borderRadius: 2,
backgroundColor: "white"
}}
>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Kennzahl
</Typography>
<TextField
fullWidth
label="Name *"
value={formData.name || ''}
onChange={(e) => updateField('name', e.target.value)}
sx={{ mb: 2 }}
required
error={!formData.name?.trim()}
helperText={!formData.name?.trim() ? 'Name ist erforderlich' : ''}
/>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beschreibung
</Typography>
<TextField
fullWidth
multiline
rows={3}
label="Beschreibung"
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
helperText="Beschreibung der Kennzahl"
/>
<Box mt={3}>
<FormControlLabel
control={
<Checkbox
checked={formData.mandatory || false}
onChange={(e) => updateField('mandatory', e.target.checked)}
sx={{ color: '#383838' }}
/>
}
label="Erforderlich"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl erlaubt keine leeren Werte
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Format: {typeDisplayMapping[formData.type as keyof typeof typeDisplayMapping] || formData.type}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Typ</InputLabel>
<Select
value={formData.type || 'string'}
label="Typ"
onChange={(e) => updateField('type', e.target.value)}
>
<MenuItem value="string">Text</MenuItem>
<MenuItem value="number">Zahl</MenuItem>
<MenuItem value="date">Datum</MenuItem>
<MenuItem value="boolean">Ja/Nein</MenuItem>
<MenuItem value="array">Liste (mehrfach)</MenuItem>
</Select>
</FormControl>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Synonyme & Übersetzungen
</Typography>
<TextField
fullWidth
label="Übersetzung"
value={formData.translation || ''}
onChange={(e) => updateField('translation', e.target.value)}
helperText="z.B. Englische Übersetzung der Kennzahl"
/>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispiele von Kennzahl
</Typography>
<TextField
fullWidth
multiline
rows={2}
label="Beispiel"
value={formData.example || ''}
onChange={(e) => updateField('example', e.target.value)}
helperText="Beispielwerte für diese Kennzahl"
/>
</Box>
{mode === 'add' && (
<>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<FormControlLabel
control={
<Checkbox
checked={formData.active !== false}
onChange={(e) => updateField('active', e.target.checked)}
sx={{ color: '#383838' }}
/>
}
label="Aktiv"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl ist aktiv und wird angezeigt
</Typography>
</Box>
</>
)}
<Box display="flex" justifyContent="flex-end" gap={2} mt={4}>
<Button
variant="outlined"
onClick={handleCancel}
disabled={isSaving}
sx={{
borderColor: "#383838",
color: "#383838",
"&:hover": { borderColor: "#2e2e2e", backgroundColor: "#f5f5f5" }
}}
>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formData.name?.trim()}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
{isSaving ? (
<>
<CircularProgress size={16} sx={{ mr: 1, color: 'white' }} />
{mode === 'add' ? 'Hinzufügen...' : 'Speichern...'}
</>
) : (
mode === 'add' ? 'Hinzufügen' : 'Speichern'
)}
</Button>
</Box>
</Paper>
);
}

View File

@ -11,12 +11,20 @@
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as ConfigAddImport } from './routes/config-add'
import { Route as ConfigImport } from './routes/config'
import { Route as IndexImport } from './routes/index'
import { Route as ExtractedResultPitchBookImport } from './routes/extractedResult.$pitchBook'
import { Route as ConfigDetailKpiIdImport } from './routes/config-detail.$kpiId'
// Create/Update Routes
const ConfigAddRoute = ConfigAddImport.update({
id: '/config-add',
path: '/config-add',
getParentRoute: () => rootRoute,
} as any)
const ConfigRoute = ConfigImport.update({
id: '/config',
path: '/config',
@ -35,6 +43,12 @@ const ExtractedResultPitchBookRoute = ExtractedResultPitchBookImport.update({
getParentRoute: () => rootRoute,
} as any)
const ConfigDetailKpiIdRoute = ConfigDetailKpiIdImport.update({
id: '/config-detail/$kpiId',
path: '/config-detail/$kpiId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -53,6 +67,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConfigImport
parentRoute: typeof rootRoute
}
'/config-add': {
id: '/config-add'
path: '/config-add'
fullPath: '/config-add'
preLoaderRoute: typeof ConfigAddImport
parentRoute: typeof rootRoute
}
'/config-detail/$kpiId': {
id: '/config-detail/$kpiId'
path: '/config-detail/$kpiId'
fullPath: '/config-detail/$kpiId'
preLoaderRoute: typeof ConfigDetailKpiIdImport
parentRoute: typeof rootRoute
}
'/extractedResult/$pitchBook': {
id: '/extractedResult/$pitchBook'
path: '/extractedResult/$pitchBook'
@ -68,12 +96,16 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
}
@ -81,27 +113,49 @@ export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/config' | '/extractedResult/$pitchBook'
fullPaths:
| '/'
| '/config'
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/config' | '/extractedResult/$pitchBook'
id: '__root__' | '/' | '/config' | '/extractedResult/$pitchBook'
to:
| '/'
| '/config'
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
id:
| '__root__'
| '/'
| '/config'
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConfigRoute: typeof ConfigRoute
ConfigAddRoute: typeof ConfigAddRoute
ConfigDetailKpiIdRoute: typeof ConfigDetailKpiIdRoute
ExtractedResultPitchBookRoute: typeof ExtractedResultPitchBookRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConfigRoute: ConfigRoute,
ConfigAddRoute: ConfigAddRoute,
ConfigDetailKpiIdRoute: ConfigDetailKpiIdRoute,
ExtractedResultPitchBookRoute: ExtractedResultPitchBookRoute,
}
@ -117,6 +171,8 @@ export const routeTree = rootRoute
"children": [
"/",
"/config",
"/config-add",
"/config-detail/$kpiId",
"/extractedResult/$pitchBook"
]
},
@ -126,6 +182,12 @@ export const routeTree = rootRoute
"/config": {
"filePath": "config.tsx"
},
"/config-add": {
"filePath": "config-add.tsx"
},
"/config-detail/$kpiId": {
"filePath": "config-detail.$kpiId.tsx"
},
"/extractedResult/$pitchBook": {
"filePath": "extractedResult.$pitchBook.tsx"
}

View File

@ -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<Kennzahl>) => {
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 (
<Box
minHeight="100vh"
width="100vw"
bgcolor="#f5f5f5"
display="flex"
flexDirection="column"
alignItems="center"
pt={3}
pb={4}
>
<Box
width="100%"
display="flex"
justifyContent="flex-start"
alignItems="center"
px={4}
mb={4}
>
<Box display="flex" alignItems="center">
<IconButton onClick={() => navigate({ to: "/config" })}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Neue Kennzahl hinzufügen
</Typography>
</Box>
</Box>
<KPIForm
mode="add"
onSave={handleSave}
onCancel={handleCancel}
/>
</Box>
);
}

View File

@ -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<Kennzahl | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Kennzahl>) => {
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 (
<Box
minHeight="100vh"
width="100vw"
bgcolor="#f5f5f5"
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<CircularProgress sx={{ color: '#383838', mb: 2 }} />
<Typography>Lade KPI Details...</Typography>
</Box>
);
}
if (error || !kennzahl) {
return (
<Box
minHeight="100vh"
width="100vw"
bgcolor="#f5f5f5"
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<Typography variant="h6" mb={2}>
{error || 'KPI nicht gefunden'}
</Typography>
<Button
variant="contained"
onClick={() => navigate({ to: "/config" })}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
Zurück zur Konfiguration
</Button>
</Box>
);
}
if (!isEditing) {
return (
<Box
minHeight="100vh"
width="100vw"
bgcolor="#f5f5f5"
display="flex"
flexDirection="column"
alignItems="center"
pt={3}
pb={4}
>
<Box
width="100%"
display="flex"
justifyContent="space-between"
alignItems="center"
px={4}
mb={4}
>
<Box display="flex" alignItems="center">
<IconButton onClick={() => navigate({ to: "/config" })}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Detailansicht
</Typography>
</Box>
<Button
variant="contained"
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
onClick={() => setIsEditing(true)}
>
Bearbeiten
</Button>
</Box>
<Paper
elevation={2}
sx={{
width: "90%",
maxWidth: 800,
p: 4,
borderRadius: 2,
backgroundColor: "white"
}}
>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Kennzahl
</Typography>
<Typography variant="body1" sx={{ mb: 2, fontSize: 16 }}>
{kennzahl.name}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beschreibung
</Typography>
<Typography variant="body1" color="text.secondary">
{kennzahl.description || "Zurzeit ist die Beschreibung der Kennzahl leer. Klicken Sie auf den Bearbeiten-Button, um die Beschreibung zu ergänzen."}
</Typography>
<Box mt={2}>
<Typography variant="body2" color="text.secondary">
<strong>Erforderlich:</strong> {kennzahl.mandatory ? 'Ja' : 'Nein'}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Format
</Typography>
<Typography variant="body1" color="text.secondary">
{typeDisplayMapping[kennzahl.type] || kennzahl.type}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Synonyme & Übersetzungen
</Typography>
<Typography variant="body1" color="text.secondary">
{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."}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispiele von Kennzahl
</Typography>
<Typography variant="body1" color="text.secondary">
{kennzahl.example || "Zurzeit gibt es keine Beispiele der Kennzahl. Klicken Sie auf den Bearbeiten-Button, um die Liste zu ergänzen."}
</Typography>
</Box>
</Paper>
</Box>
);
}
return (
<Box
minHeight="100vh"
width="100vw"
bgcolor="#f5f5f5"
display="flex"
flexDirection="column"
alignItems="center"
pt={3}
pb={4}
>
<Box
width="100%"
display="flex"
justifyContent="flex-start"
alignItems="center"
px={4}
mb={4}
>
<Box display="flex" alignItems="center">
<IconButton onClick={() => navigate({ to: "/config" })}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Kennzahl bearbeiten
</Typography>
</Box>
</Box>
<KPIForm
mode="edit"
initialData={kennzahl}
onSave={handleSave}
onCancel={handleCancel}
/>
</Box>
);
}

View File

@ -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 (
<Box
height="100vh"
minHeight="100vh"
width="100vw"
bgcolor="white"
display="flex"
flexDirection="column"
alignItems="center"
pt={3}
pb={4}
>
<Box
width="100%"
@ -37,6 +43,7 @@ function ConfigPage() {
</Box>
<Button
variant="contained"
onClick={handleAddNewKPI}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
@ -45,22 +52,9 @@ function ConfigPage() {
Neue Kennzahl hinzufügen
</Button>
</Box>
<Paper
elevation={2}
sx={{
width: "90%",
maxWidth: 1100,
height: 400,
mt: 4,
borderRadius: 2,
backgroundColor: "#eeeeee",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="textSecondary">To-do: Table hierhin</Typography>
</Paper>
<Box sx={{ width: "100%", mt: 4, display: "flex", justifyContent: "center" }}>
<ConfigTable />
</Box>
</Box>
);
}

View File

@ -0,0 +1,23 @@
export interface Kennzahl {
id: number;
name: string;
description: string;
mandatory: boolean;
type: string;
translation: string;
example: string;
position: number;
active: boolean;
}
export const typeDisplayMapping: Record<string, string> = {
"string": "Text",
"date": "Datum",
"boolean": "Ja/Nein",
"number": "Zahl",
"array": "Liste (mehrfach)"
};
export const getDisplayType = (backendType: string): string => {
return typeDisplayMapping[backendType] || backendType;
};