412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
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]]
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
|