pse2_ff/project/frontend/src/components/KPIForm.tsx

412 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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";
import Snackbar from "@mui/material/Snackbar";
import MuiAlert from "@mui/material/Alert";
interface KPIFormProps {
mode: 'add' | 'edit';
initialData?: Kennzahl | null;
onSave: (data: Partial<Kennzahl>) => Promise<void>;
onCancel: () => void;
loading?: boolean;
resetTrigger?: number;
}
const emptyKPI: Partial<Kennzahl> = {
name: '',
mandatory: false,
type: 'string',
active: true,
examples: [{ sentence: '', value: '' }],
};
export function KPIForm({ mode, initialData, onSave, onCancel, loading = false, resetTrigger }: KPIFormProps) {
const [formData, setFormData] = useState<Partial<Kennzahl>>(emptyKPI);
const [isSaving, setIsSaving] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarSeverity, setSnackbarSeverity] = useState<'success' | 'error' | 'info'>("success");
useEffect(() => {
if (mode === 'edit' && initialData) {
setFormData(initialData);
} else if (mode === 'add') {
setFormData(emptyKPI);
}
}, [mode, initialData]);
useEffect(() => {
if (mode === 'add') {
setFormData(emptyKPI);
}
}, [resetTrigger]);
const handleSave = async () => {
if (!formData.name?.trim()) {
setSnackbarMessage("Name ist erforderlich");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
if (!formData.examples || formData.examples.length === 0) {
setSnackbarMessage("Mindestens ein Beispielsatz ist erforderlich");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
for (const ex of formData.examples) {
if (!ex.sentence?.trim() || !ex.value?.trim()) {
setSnackbarMessage('Alle Beispielsätze müssen vollständig sein.');
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
}
setIsSaving(true);
try {
const spacyEntries = generateSpacyEntries(formData);
// Für jeden einzelnen Beispielsatz:
for (const entry of spacyEntries) {
// im localStorage speichern (zum Debuggen oder Vorschau)
const stored = localStorage.getItem("spacyData");
const existingData = stored ? JSON.parse(stored) : [];
const updated = [...existingData, entry];
localStorage.setItem("spacyData", JSON.stringify(updated));
// POST Request an das Flask-Backend
const response = await fetch("http://localhost:5050/api/spacy/append-training-entry", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(entry)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Fehler beim Aufruf von append-training-entry");
}
console.log("SpaCy-Eintrag gespeichert:", data);
}
// Dann in die DB speichern
await onSave({
name: formData.name!,
mandatory: formData.mandatory ?? false,
type: formData.type || 'string',
position: formData.position ?? 0,
active: formData.active ?? true,
examples: formData.examples ?? [],
is_trained: false,
});
// Formular zurücksetzen:
setFormData(emptyKPI);
setSnackbarMessage("Beispielsätze gespeichert. Jetzt auf -Neu trainieren- klicken oder weitere Kennzahlen hinzufügen.");
setSnackbarSeverity("success");
setSnackbarOpen(true);
} catch (e: any) {
// Prüfe auf 409-Fehler
if (e?.message?.includes("409") || e?.response?.status === 409) {
setSnackbarMessage("Diese Kennzahl existiert bereits. Sie können sie unter -Konfiguration- bearbeiten.");
setSnackbarSeverity("info");
setSnackbarOpen(true);
} else {
setSnackbarMessage(e.message || "Fehler beim Speichern.");
setSnackbarSeverity("error");
setSnackbarOpen(true);
}
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setFormData(emptyKPI);
onCancel();
};
const updateField = (field: keyof Kennzahl, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const updateExample = (index: number, field: 'sentence' | 'value', value: string) => {
const newExamples = [...(formData.examples || [])];
newExamples[index][field] = value;
updateField('examples', newExamples);
};
const addExample = () => {
const newExamples = [...(formData.examples || []), { sentence: '', value: '' }];
updateField('examples', newExamples);
};
const removeExample = (index: number) => {
const newExamples = [...(formData.examples || [])];
newExamples.splice(index, 1);
updateField('examples', newExamples);
};
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 *"
placeholder="z.B. IRR"
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}>
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>
{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 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>
</>
)}
<Divider sx={{ my: 3 }} />
{/* Hinweistext vor Beispielsätzen */}
<Box mb={2} p={2} sx={{ backgroundColor: '#fff8e1', border: '1px solid #ffe082', borderRadius: 2 }}>
<Typography variant="body1" sx={{ fontWeight: 'bold', mb: 1 }}>
Hinweis zur Trainingsqualität
</Typography>
<Typography variant="body2">
Damit das System neue Kennzahlen zuverlässig erkennen kann, empfehlen wir <strong>mindestens 5 Beispielsätze</strong> zu erstellen je mehr, desto besser.
</Typography>
<Typography variant="body2" mt={1}>
<strong>Wichtig:</strong> Neue Kennzahlen werden erst in PDF-Dokumenten erkannt, wenn Sie den Button <em>"Neu trainieren"</em> auf der Konfigurationsseite ausführen.
</Typography>
<Typography variant="body2" mt={1}>
<strong>Tipp:</strong> Sie können jederzeit weitere Beispielsätze hinzufügen oder vorhandene in der Kennzahlenverwaltung bearbeiten.
</Typography>
</Box>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispielsätze
</Typography>
{(formData.examples || []).map((ex, idx) => (
<Box key={idx} sx={{ mb: 2, border: '1px solid #ccc', p: 2, borderRadius: 1 }}>
<TextField
fullWidth
multiline
label={`Beispielsatz ${idx + 1}`}
placeholder="z.B. Die IRR beträgt 7,8 %"
value={ex.sentence}
onChange={(e) => updateExample(idx, 'sentence', e.target.value)}
required
sx={{ mb: 1 }}
/>
<TextField
fullWidth
label="Bezeichneter Wert im Satz"
placeholder="z.B. 7,8 %"
value={ex.value}
onChange={(e) => updateExample(idx, 'value', e.target.value)}
required
/>
{(formData.examples?.length || 0) > 1 && (
<Button onClick={() => removeExample(idx)} sx={{ mt: 1 }} color="error">
Entfernen
</Button>
)}
</Box>
))}
<Button variant="outlined" onClick={addExample}>
+ Beispielsatz hinzufügen
</Button>
</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>
<Snackbar
open={snackbarOpen}
autoHideDuration={5000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<MuiAlert
elevation={6}
variant="filled"
onClose={() => setSnackbarOpen(false)}
severity={snackbarSeverity}
sx={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<span>{snackbarMessage}</span>
<Button color="inherit" size="small" onClick={() => setSnackbarOpen(false)}>
OK
</Button>
</MuiAlert>
</Snackbar>
</>
);
}
function generateSpacyEntries(formData: Partial<Kennzahl>) {
const label = formData.name?.trim().toUpperCase() || "";
return (formData.examples || []).map(({ sentence, value }) => {
const trimmedValue = value.trim();
const start = sentence.indexOf(trimmedValue);
if (start === -1) {
throw new Error(`"${trimmedValue}" nicht gefunden in Satz: "${sentence}"`);
}
return {
text: sentence,
entities: [[start, start + trimmedValue.length, label]]
};
});
}