#19-Konfiguration-anzeigen #55
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -16,4 +16,6 @@ RUN python -m spacy download en_core_web_sm
|
|||
|
||||
COPY .. /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["python3.12", "app.py"]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
||||
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
3025495
commented
Review
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).
Diese Liste, die häufiger in den Code-Beispielen vorkommt: 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
3025495
commented
Review
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:
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);
```
3019483
commented
Review
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
3025495
commented
Review
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
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.