frontend_nyla/src/pages/admin/AdminFeatureAccessPage.tsx
2026-04-26 18:11:52 +02:00

715 lines
28 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 { useFeatureStore } from '../../stores/featureStore';
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 { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { ChatbotConfigSection } from './ChatbotConfigSection';
import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminFeatureAccessPage: React.FC = () => {
const { t } = useLanguage();
const {
features,
instances,
instancesPagination,
loading,
error,
fetchFeatures,
fetchInstances,
createInstance,
updateInstance,
deleteInstance,
syncInstanceRoles,
syncInstanceWorkflows,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
const { loadFeatures } = useFeatureStore();
const { request } = useApiRequest();
// 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 [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = 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
const [chatbotAllowedProviders, setChatbotAllowedProviders] = useState<string[]>([]); // Allowed LLM providers (empty = all)
// 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(data => {
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
});
// 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]);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
{
key: 'featureCode',
label: t('Feature'),
sortable: true,
filterable: true,
width: 150,
formatter: (value: string) => {
const feature = features.find(f => f.code === value);
const label = feature ? (feature.label || value) : value;
return label;
},
},
{ key: 'enabled', label: t('Aktiv'), sortable: true, filterable: true, width: 80 },
], [features, t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
// 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(t('Fehler'), t('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(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
setIsSubmitting(false);
return;
}
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
config = {
connector: chatbotConnectors.length > 0 ? {
types: chatbotConnectors,
type: primaryConnector,
customConnectorClass: null
} : undefined,
prompts: {
useCustomPrompts: true,
customAnalysisPrompt: chatbotSystemPrompt,
customFinalAnswerPrompt: chatbotSystemPrompt
},
behavior: {
maxQueries: 5,
enableWebResearch: chatbotEnableWebResearch,
enableRetryOnEmpty: true,
maxRetryAttempts: 2
},
allowedProviders: chatbotAllowedProviders
};
}
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);
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
} else {
showError(t('Fehler'), result.error || t('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);
setChatbotSystemPrompt(config?.prompts?.customAnalysisPrompt || config?.prompts?.customFinalAnswerPrompt || '');
setChatbotEnableWebResearch(config?.behavior?.enableWebResearch !== false);
setChatbotAllowedProviders(Array.isArray(config?.allowedProviders) ? config.allowedProviders
: Array.isArray(config?.model?.allowedProviders) ? config.model.allowedProviders : []);
} else {
setChatbotConnectors([]);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}
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(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
setIsSubmitting(false);
return;
}
const existingConfig = editingInstance.config as any || {};
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
config = {
...existingConfig,
connector: chatbotConnectors.length > 0 ? {
types: chatbotConnectors,
type: primaryConnector,
customConnectorClass: existingConfig.connector?.customConnectorClass || null
} : undefined,
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
},
allowedProviders: chatbotAllowedProviders
};
}
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('');
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
} else {
showError(t('Fehler'), result.error || t('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) {
loadFeatures(); // Refresh global navigation cache
showSuccess(t('Instanz gelöscht'), t('Die Feature-Instanz wurde gelöscht.'));
return true;
} else {
showError(t('Fehler'), result.error || t('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(
t('Rollen synchronisiert'),
t('Hinzugefügt: {added}\nEntfernt: {removed}\nUnverändert: {unchanged}', {
added: result.data.added,
removed: result.data.removed,
unchanged: result.data.unchanged,
})
);
} else {
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren der Rollen'));
}
} finally {
setSyncingInstance(null);
}
};
// Handle sync workflows
const _handleSyncWorkflows = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
setSyncingWorkflowsInstance(instance.id);
try {
const result = await syncInstanceWorkflows(selectedMandateId, instance.id);
if (result.success && result.data) {
showSuccess(
t('Workflows synchronisiert'),
t('Hinzugefügt: {added}\nÜbersprungen: {skipped}\nTotal Templates: {total}', {
added: result.data.added,
skipped: result.data.skipped,
total: result.data.total,
})
);
} else {
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren der Workflows'));
}
} finally {
setSyncingWorkflowsInstance(null);
}
};
// Get feature label
const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code);
return feature ? (feature.label || code) : code;
};
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
</div>
</div>
{/* Mandate Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant auswählen:')}
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchInstances(selectedMandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
disabled={features.length === 0}
title={
features.length === 0
? t('Keine Features verfügbar. Bitte laden Sie die Seite neu oder prüfen Sie die Konsole auf Fehler.')
: undefined
}
>
<FaPlus /> {t('Neue Instanz')}
</button>
</div>
)}
</div>
{/* Available Features Info / Empty Features Warning */}
{features.length > 0 ? (
<div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} />
<span>{t('Verfügbare Features')} </span>
{features.map((f, i) => (
<span key={f.code}>
{i > 0 && ', '}
<strong>{getFeatureLabel(f.code)}</strong>
</span>
))}
</div>
) : selectedMandateId && !loading ? (
<div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}>
<FaCube style={{ marginRight: 8 }} />
<span>
{t('Keine Features geladen.')}
{error ? ` Fehler: ${error}` : ` ${t('Die API hat keine Features zurückgegeben.')}`}
{' '}
{t('Öffnen Sie die Browser-Konsole (F12) und prüfen Sie den Netzwerk-Tab für /api/features/')}
</span>
<button
className={styles.secondaryButton}
onClick={() => fetchFeatures()}
style={{ marginLeft: '1rem' }}
>
<FaSync /> {t('Features erneut laden')}
</button>
</div>
) : null}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')}
</p>
</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={true}
actionButtons={[
{
type: 'delete' as const,
title: t('Instanz löschen'),
}
]}
customActions={[
{
id: 'edit',
icon: <FaEdit />,
onClick: handleEditClick,
title: t('Instanz bearbeiten'),
},
{
id: 'syncRoles',
icon: <FaCogs />,
onClick: handleSyncRoles,
title: t('Rollen synchronisieren'),
loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
},
{
id: 'syncWorkflows',
icon: <FaSync />,
onClick: _handleSyncWorkflows,
title: t('Workflows synchronisieren'),
loading: (row: FeatureInstance) => syncingWorkflowsInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
}
]}
hookData={{
refetch: fetchInstances,
pagination: instancesPagination,
handleDelete: handleDeleteInstance,
}}
emptyMessage={t('Keine Feature-Instanzen gefunden')}
/>
</div>
)}
{/* Create Instance Modal */}
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{features.length === 0 ? (
<p>{t('Keine Features verfügbar, bitte wenden')}</p>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<div>
{/* Feature Code Selector — buttons instead of dropdown */}
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}>
{features.map(f => (
<button
key={f.code}
type="button"
className={styles.secondaryButton}
style={{
padding: '0.5rem 1rem',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: createFeatureCode === f.code ? 600 : 400,
background: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
color: createFeatureCode === f.code ? '#fff' : undefined,
borderColor: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
}}
onClick={() => {
setCreateFeatureCode(f.code);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
>
{f.label || f.code}
</button>
))}
</div>
</div>
{/* Chatbot Configuration Title - Show when chatbot is selected */}
{createFeatureCode === 'chatbot' && (
<h3 className={styles.configSectionTitle} style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
{t('Chatbot-Konfiguration')}
</h3>
)}
{/* Label Field - Always shown after title */}
{createFeatureCode && (
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
<label className={styles.configLabel}>
{t('Label')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<TextField
type="text"
value={createLabel}
onChange={(value) => setCreateLabel(value)}
placeholder={t('Instanzbezeichnung 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}
allowedProviders={chatbotAllowedProviders}
onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch}
onAllowedProvidersChange={setChatbotAllowedProviders}
/>
)}
{/* 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);
setChatbotAllowedProviders([]);
}}
submitButtonText={t('Erstellen')}
cancelButtonText={t('Abbrechen')}
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Edit Instance Modal */}
{showEditModal && editingInstance && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('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: t('Bezeichnung'),
required: true,
editable: true,
},
{
name: 'enabled',
type: 'boolean' as const,
label: t('Aktiviert'),
required: false,
editable: true,
}
]}
data={editingInstance}
mode="edit"
onSubmit={handleUpdateInstance}
onCancel={() => {
setShowEditModal(false);
setEditingInstance(null);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
{/* Chatbot Configuration Section */}
{editingInstance?.featureCode === 'chatbot' && (
<ChatbotConfigSection
connectors={chatbotConnectors}
systemPrompt={chatbotSystemPrompt}
enableWebResearch={chatbotEnableWebResearch}
allowedProviders={chatbotAllowedProviders}
onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch}
onAllowedProvidersChange={setChatbotAllowedProviders}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureAccessPage;