438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
/**
|
||
* AdminFeatureAccessPage
|
||
*
|
||
* Admin page for managing feature instances within mandates.
|
||
* Allows creating, viewing, and managing feature instances.
|
||
*/
|
||
|
||
import React, { useState, useEffect, useMemo } 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 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[]>([]);
|
||
|
||
// 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
|
||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||
const excludedFields = ['id', 'mandateId'];
|
||
const featureOptions = features.map(f => ({
|
||
value: f.code,
|
||
label: typeof f.label === 'object'
|
||
? (f.label.de || f.label.en || f.code)
|
||
: (f.label || f.code)
|
||
}));
|
||
|
||
return backendAttributes
|
||
.filter(attr => !excludedFields.includes(attr.name))
|
||
.map(attr => ({
|
||
...attr,
|
||
// Override featureCode: make editable for create and add dynamic options
|
||
readonly: attr.name === 'featureCode' ? false : attr.readonly,
|
||
editable: attr.name === 'featureCode' || attr.name === 'enabled' ? true : attr.editable,
|
||
options: attr.name === 'featureCode' ? featureOptions : attr.options,
|
||
})) as AttributeDefinition[];
|
||
}, [features, backendAttributes]);
|
||
|
||
// Handle create instance
|
||
const handleCreateInstance = async (data: { featureCode: string; label: string; enabled?: boolean; copyTemplateRoles?: boolean }) => {
|
||
if (!selectedMandateId) return;
|
||
setIsSubmitting(true);
|
||
try {
|
||
const result = await createInstance(selectedMandateId, {
|
||
featureCode: data.featureCode,
|
||
label: data.label,
|
||
enabled: data.enabled !== false,
|
||
copyTemplateRoles: data.copyTemplateRoles !== false
|
||
});
|
||
if (result.success) {
|
||
setShowCreateModal(false);
|
||
fetchInstances(selectedMandateId);
|
||
showSuccess('Feature-Instanz erstellt', `Die Instanz "${data.label}" wurde erfolgreich erstellt.`);
|
||
} else {
|
||
showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz');
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle edit click
|
||
const handleEditClick = (instance: FeatureInstance) => {
|
||
setEditingInstance(instance);
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
// Handle update instance
|
||
const handleUpdateInstance = async (data: { label: string; enabled?: boolean }) => {
|
||
if (!selectedMandateId || !editingInstance) return;
|
||
setIsSubmitting(true);
|
||
try {
|
||
const result = await updateInstance(selectedMandateId, editingInstance.id, {
|
||
label: data.label,
|
||
enabled: data.enabled
|
||
});
|
||
if (result.success) {
|
||
setShowEditModal(false);
|
||
setEditingInstance(null);
|
||
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 (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}
|
||
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>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={createFields}
|
||
mode="create"
|
||
onSubmit={handleCreateInstance}
|
||
onCancel={() => setShowCreateModal(false)}
|
||
submitButtonText="Erstellen"
|
||
cancelButtonText="Abbrechen"
|
||
/>
|
||
)}
|
||
</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); }}
|
||
submitButtonText="Speichern"
|
||
cancelButtonText="Abbrechen"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminFeatureAccessPage;
|