ui-nyla/src/pages/admin/AdminFeatureAccessPage.tsx
ValueOn AG 0ad9006b94
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
panel fixes 3
2026-06-11 22:55:09 +02:00

595 lines
22 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.

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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 { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
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();
// 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[]>([]);
// Instance creation state
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
const formDataRef = useRef<Record<string, any>>({});
const _lastTableParams = useRef<any>(undefined);
// 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]);
const _refetchInstances = React.useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
if (paginationParams && typeof paginationParams === 'object') {
_lastTableParams.current = paginationParams;
} else {
paginationParams = _lastTableParams.current;
}
if (paginationParams) return fetchInstances(paginationParams);
return fetchInstances(selectedMandateId);
}, [selectedMandateId, fetchInstances]);
useEffect(() => {
if (selectedMandateId) {
_lastTableParams.current = undefined;
}
}, [selectedMandateId]);
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;
}
const result = await createInstance(selectedMandateId, {
featureCode: createFeatureCode,
label: createLabel,
enabled: data.enabled !== false,
copyTemplateRoles: data.copyTemplateRoles !== false,
});
if (result.success) {
setShowCreateModal(false);
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
_refetchInstances();
loadFeatures();
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);
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);
_refetchInstances();
loadFeatures();
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 (
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card" title={t('Fehler')} id="feature-access-error">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler')}: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar" title={t('Filter')} id="feature-access-toolbar">
<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={() => _refetchInstances()}
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>
</Panel>
{features.length > 0 ? (
<Panel variant="card" title={t('Verfügbare Features')} id="feature-access-features">
<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>
</Panel>
) : selectedMandateId && !loading ? (
<Panel variant="card" title={t('Keine Features geladen')} id="feature-access-no-features">
<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>
</Panel>
) : null}
{!selectedMandateId ? (
<Panel variant="card" title={t('Kein Mandant ausgewählt')} id="feature-access-no-mandate">
<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>
</Panel>
) : (
<Panel variant="table" title={t('Feature-Instanzen')} id="feature-access-table">
<FormGeneratorTable
data={instances}
columns={columns}
apiEndpoint="/api/features/instances"
filterScopeKey={selectedMandateId || 'admin'}
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: _refetchInstances,
pagination: instancesPagination,
handleDelete: handleDeleteInstance,
}}
emptyMessage={t('Keine Feature-Instanzen gefunden')}
/>
</Panel>
)}
</StackLayout.Body>
</StackLayout>
{/* 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);
}}
>
{f.label || f.code}
</button>
))}
</div>
</div>
{/* 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>
)}
{/* 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 = {};
}}
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);
}}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</div>
</div>
</div>
)}
</>
);
};
export default AdminFeatureAccessPage;