frontend_nyla/src/pages/admin/AdminFeatureAccessPage.tsx
2026-02-10 00:10:10 +01:00

667 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AdminFeatureAccessPage
*
* Admin page for managing feature instances within mandates.
* Allows creating, viewing, and managing feature instances.
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { ChatbotConfigSection } from './ChatbotConfigSection';
import { DropdownSelect } from '../../components/UiComponents/DropdownSelect';
import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
export const AdminFeatureAccessPage: React.FC = () => {
const {
features,
instances,
instancesPagination,
loading,
error,
fetchFeatures,
fetchInstances,
createInstance,
updateInstance,
deleteInstance,
syncInstanceRoles,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Chatbot configuration state
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
const [chatbotConnectors, setChatbotConnectors] = useState<string[]>(['preprocessor']); // Array for multiselect (database connectors only)
const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState<string>('');
const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState<boolean>(true); // Enable Tavily web research
// Ref to track form data for featureCode detection
const formDataRef = useRef<Record<string, any>>({});
// Load features, mandates, and attributes on mount
useEffect(() => {
fetchFeatures();
fetchMandates().then(setMandates);
// Fetch FeatureInstance attributes from backend
api.get('/api/attributes/FeatureInstance').then(response => {
const attrs = response.data?.attributes || response.data || [];
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
}).catch(() => setBackendAttributes([]));
}, [fetchFeatures, fetchMandates]);
// Load instances when mandate changes
useEffect(() => {
if (selectedMandateId) {
fetchInstances(selectedMandateId);
}
}, [selectedMandateId, fetchInstances]);
// Table columns
const columns = useMemo(() => [
{ key: 'label', label: 'Name', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
{ key: 'featureCode', label: 'Feature', type: 'string' as const, sortable: true, filterable: true, width: 150,
render: (value: string) => {
const feature = features.find(f => f.code === value);
if (feature) {
const label = typeof feature.label === 'object'
? (feature.label.de || feature.label.en || value)
: feature.label;
return label;
}
return value;
}
},
{ key: 'enabled', label: 'Aktiv', type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
], [features]);
// Form attributes from backend - merge with dynamic feature options
// Exclude featureCode, config, and label since we handle them separately
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureCode', 'config', 'label']; // Exclude featureCode, config, and label - handled separately
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
editable: attr.name === 'enabled' ? true : attr.editable,
})) as AttributeDefinition[];
}, [backendAttributes]);
// Handle create instance
const handleCreateInstance = async (data: { featureCode: string; enabled?: boolean; copyTemplateRoles?: boolean }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
// Validate label
if (!createLabel || createLabel.trim() === '') {
showError('Fehler', 'Label ist erforderlich.');
setIsSubmitting(false);
return;
}
// Build config for chatbot instances
let config: Record<string, any> | undefined = undefined;
if (createFeatureCode === 'chatbot') {
// Validate required fields
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
showError('Fehler', 'System Prompt ist erforderlich für Chatbot-Instanzen.');
setIsSubmitting(false);
return;
}
if (chatbotConnectors.length === 0) {
showError('Fehler', 'Mindestens ein Connector muss ausgewählt werden.');
setIsSubmitting(false);
return;
}
// Use first connector as primary type (for backward compatibility)
// Store all connectors in types array
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : 'preprocessor';
config = {
connector: {
types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors
type: primaryConnector, // Primary connector (for backward compatibility)
customConnectorClass: null
},
prompts: {
useCustomPrompts: true, // Always true since system prompt is required
customAnalysisPrompt: chatbotSystemPrompt,
customFinalAnswerPrompt: chatbotSystemPrompt
},
behavior: {
maxQueries: 5,
enableWebResearch: chatbotEnableWebResearch,
enableRetryOnEmpty: true,
maxRetryAttempts: 2
}
};
}
const result = await createInstance(selectedMandateId, {
featureCode: createFeatureCode,
label: createLabel,
enabled: data.enabled !== false,
copyTemplateRoles: data.copyTemplateRoles !== false,
config: config
});
if (result.success) {
setShowCreateModal(false);
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
fetchInstances(selectedMandateId);
showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`);
} else {
showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz');
}
} finally {
setIsSubmitting(false);
}
};
// Wrapper for form submission that includes featureCode from selector
const handleFormSubmit = (data: Record<string, any>) => {
// Use label from state and featureCode from selector
handleCreateInstance({
featureCode: createFeatureCode,
...(data as { enabled?: boolean; copyTemplateRoles?: boolean })
});
};
// Handle edit click
const handleEditClick = (instance: FeatureInstance) => {
setEditingInstance(instance);
// Load chatbot config if it's a chatbot instance
if (instance.featureCode === 'chatbot' && instance.config) {
const config = instance.config as any;
// Support both new array format and legacy single type format
// Filter out 'websearch' if it exists (legacy)
const connectorTypes = (config?.connector?.types || (config?.connector?.type ? [config.connector.type] : ['preprocessor']))
.filter((c: string) => c !== 'websearch'); // Remove websearch from connectors
setChatbotConnectors(connectorTypes.length > 0 ? connectorTypes : ['preprocessor']);
setChatbotSystemPrompt(config?.prompts?.customAnalysisPrompt || config?.prompts?.customFinalAnswerPrompt || '');
setChatbotEnableWebResearch(config?.behavior?.enableWebResearch !== false); // Default to true if not set
} else {
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
}
setShowEditModal(true);
};
// Handle update instance
const handleUpdateInstance = async (data: { label: string; enabled?: boolean }) => {
if (!selectedMandateId || !editingInstance) return;
setIsSubmitting(true);
try {
// Build config for chatbot instances
let config: Record<string, any> | undefined = undefined;
if (editingInstance.featureCode === 'chatbot') {
// Validate required fields
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
showError('Fehler', 'System Prompt ist erforderlich für Chatbot-Instanzen.');
setIsSubmitting(false);
return;
}
if (chatbotConnectors.length === 0) {
showError('Fehler', 'Mindestens ein Connector muss ausgewählt werden.');
setIsSubmitting(false);
return;
}
// Merge with existing config if it exists
const existingConfig = editingInstance.config as any || {};
// Use first connector as primary type (for backward compatibility)
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : 'preprocessor';
config = {
...existingConfig,
connector: {
types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors
type: primaryConnector, // Primary connector (for backward compatibility)
customConnectorClass: existingConfig.connector?.customConnectorClass || null
},
prompts: {
useCustomPrompts: true, // Always true since system prompt is required
customAnalysisPrompt: chatbotSystemPrompt,
customFinalAnswerPrompt: chatbotSystemPrompt
},
behavior: {
...(existingConfig.behavior || {}),
maxQueries: existingConfig.behavior?.maxQueries || 5,
enableWebResearch: chatbotEnableWebResearch,
enableRetryOnEmpty: existingConfig.behavior?.enableRetryOnEmpty !== false,
maxRetryAttempts: existingConfig.behavior?.maxRetryAttempts || 2
}
};
}
const result = await updateInstance(selectedMandateId, editingInstance.id, {
label: data.label,
enabled: data.enabled,
config: config
});
if (result.success) {
setShowEditModal(false);
setEditingInstance(null);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
fetchInstances(selectedMandateId);
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
} else {
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
}
} finally {
setIsSubmitting(false);
}
};
// Handle delete instance - called by DeleteActionButton with instanceId
const handleDeleteInstance = async (instanceId: string): Promise<boolean> => {
if (!selectedMandateId) return false;
const result = await deleteInstance(selectedMandateId, instanceId);
if (result.success) {
showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
return true;
} else {
showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
return false;
}
};
// Handle sync roles
const handleSyncRoles = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
setSyncingInstance(instance.id);
try {
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
if (result.success && result.data) {
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}\nEntfernt: ${result.data.removed}\nUnverändert: ${result.data.unchanged}`
);
} else {
showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren der Rollen');
}
} finally {
setSyncingInstance(null);
}
};
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
};
// Get feature label
const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code);
if (feature) {
return typeof feature.label === 'object'
? (feature.label.de || feature.label.en || code)
: (feature.label || code);
}
return code;
};
if (error && !selectedMandateId) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Feature-Instanzen für jeden Mandanten</p>
</div>
</div>
{/* Mandate Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant auswählen:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchInstances(selectedMandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
disabled={features.length === 0}
>
<FaPlus /> Neue Instanz
</button>
</div>
)}
</div>
{/* Available Features Info */}
{features.length > 0 && (
<div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} />
<span>Verfügbare Features: </span>
{features.map((f, i) => (
<span key={f.code}>
{i > 0 && ', '}
<strong>{getFeatureLabel(f.code)}</strong>
</span>
))}
</div>
)}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
</p>
</div>
) : loading && instances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Feature-Instanzen...</span>
</div>
) : instances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanzen</h3>
<p className={styles.emptyDescription}>
Für diesen Mandanten wurden noch keine Feature-Instanzen erstellt.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
disabled={features.length === 0}
>
<FaPlus /> Erste Instanz erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={instances}
columns={columns}
apiEndpoint="/api/features/instances"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'delete' as const,
title: 'Instanz löschen',
}
]}
customActions={[
{
id: 'edit',
icon: <FaEdit />,
onClick: handleEditClick,
title: 'Instanz bearbeiten',
},
{
id: 'syncRoles',
icon: <FaCogs />,
onClick: handleSyncRoles,
title: 'Rollen synchronisieren',
loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
}
]}
hookData={{
refetch: fetchInstances,
pagination: instancesPagination,
handleDelete: handleDeleteInstance,
}}
emptyMessage="Keine Feature-Instanzen gefunden"
/>
</div>
)}
{/* Create Instance Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Instanz erstellen</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{features.length === 0 ? (
<p>Keine Features verfügbar. Bitte wenden Sie sich an den System-Administrator.</p>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<div>
{/* Feature Code Selector - Required for chatbot config */}
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
Feature auswählen: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<DropdownSelect
items={features.map(f => ({
id: f.code,
label: typeof f.label === 'object'
? (f.label.de || f.label.en || f.code)
: (f.label || f.code),
value: f.code
}))}
selectedItemId={createFeatureCode}
onSelect={(item) => {
const selectedCode = item?.value || '';
setCreateFeatureCode(selectedCode);
// Reset chatbot config when switching
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
}}
placeholder="Feature auswählen (erforderlich)"
className={styles.configSelect}
/>
{!createFeatureCode && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
Bitte wählen Sie ein Feature aus, um fortzufahren.
</p>
)}
</div>
{/* Chatbot Configuration Title - Show when chatbot is selected */}
{createFeatureCode === 'chatbot' && (
<h3 className={styles.configSectionTitle} style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
Chatbot-Konfiguration
</h3>
)}
{/* Label Field - Always shown after title */}
{createFeatureCode && (
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
<label className={styles.configLabel}>
Label: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<TextField
type="text"
value={createLabel}
onChange={(value) => setCreateLabel(value)}
placeholder="Instanz-Bezeichnung eingeben..."
className={styles.configSelect}
size="md"
required={true}
/>
</div>
)}
{/* Chatbot Configuration Section - Show when chatbot is selected */}
{createFeatureCode === 'chatbot' && (
<ChatbotConfigSection
connectors={chatbotConnectors}
systemPrompt={chatbotSystemPrompt}
enableWebResearch={chatbotEnableWebResearch}
onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch}
/>
)}
{/* Main Form - Only show if featureCode is selected */}
{createFeatureCode && (
<div style={{ marginTop: '1.5rem' }}>
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleFormSubmit}
onCancel={() => {
setShowCreateModal(false);
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
}}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Edit Instance Modal */}
{showEditModal && editingInstance && (
<div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Feature-Instanz bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => { setShowEditModal(false); setEditingInstance(null); }}
>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={[
{
name: 'label',
type: 'string' as const,
label: 'Bezeichnung',
required: true,
editable: true,
},
{
name: 'enabled',
type: 'boolean' as const,
label: 'Aktiviert',
required: false,
editable: true,
}
]}
data={editingInstance}
mode="edit"
onSubmit={handleUpdateInstance}
onCancel={() => {
setShowEditModal(false);
setEditingInstance(null);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
}}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
{/* Chatbot Configuration Section */}
{editingInstance?.featureCode === 'chatbot' && (
<ChatbotConfigSection
connectors={chatbotConnectors}
systemPrompt={chatbotSystemPrompt}
enableWebResearch={chatbotEnableWebResearch}
onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureAccessPage;