>({
const cellValue = row[column.key];
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
const combinedClassName = `${styles.td} ${customClassName}`.trim();
- const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
- const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
- const alignStyle: React.CSSProperties = formatAlign === 'R'
- ? { textAlign: 'right' }
- : formatAlign === 'M'
- ? { textAlign: 'center' }
- : formatAlign === 'L'
- ? { textAlign: 'left' }
- : isNumeric ? { textAlign: 'right' } : {};
+ const alignStyle = _columnAlignStyle(column);
return (
@@ -2486,17 +2760,7 @@ export function FormGeneratorTable>({
const cellValue = row[column.key];
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
const combinedClassName = `${styles.td} ${customClassName}`.trim();
- const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
- // ``frontendFormat`` may carry an explicit alignment prefix
- // ("L:", "M:", "R:") that overrides the numeric default.
- const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
- const alignStyle: React.CSSProperties = formatAlign === 'R'
- ? { textAlign: 'right' }
- : formatAlign === 'M'
- ? { textAlign: 'center' }
- : formatAlign === 'L'
- ? { textAlign: 'left' }
- : isNumeric ? { textAlign: 'right' } : {};
+ const alignStyle = _columnAlignStyle(column);
return (
diff --git a/src/components/PeriodPicker/PeriodPickerPopover.tsx b/src/components/PeriodPicker/PeriodPickerPopover.tsx
index f21dd67..d1c5aec 100644
--- a/src/components/PeriodPicker/PeriodPickerPopover.tsx
+++ b/src/components/PeriodPicker/PeriodPickerPopover.tsx
@@ -5,7 +5,7 @@
* actual commit to the parent via `onApply` / `onCancel`.
*/
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar';
import {
@@ -200,6 +200,36 @@ const PeriodPickerPopover: React.FC = (props) => {
return () => window.removeEventListener('keydown', _onKey);
}, [draft, onApply, onCancel]);
+ useLayoutEffect(() => {
+ const pop = popRef.current;
+ if (!pop) return;
+ const _clamp = () => {
+ const parent = pop.parentElement;
+ if (!parent) return;
+ const pRect = parent.getBoundingClientRect();
+ const margin = 8;
+ const popW = pop.offsetWidth || 720;
+ const popH = pop.offsetHeight || 400;
+ let left = pRect.left;
+ let top = pRect.bottom + 6;
+ if (left + popW > window.innerWidth - margin) {
+ left = window.innerWidth - margin - popW;
+ }
+ if (left < margin) left = margin;
+ if (top + popH > window.innerHeight - margin) {
+ top = Math.max(margin, pRect.top - 6 - popH);
+ }
+ pop.style.position = 'fixed';
+ pop.style.left = `${left}px`;
+ pop.style.top = `${top}px`;
+ pop.style.right = 'auto';
+ pop.style.zIndex = '2001';
+ };
+ _clamp();
+ const id = requestAnimationFrame(() => _clamp());
+ return () => cancelAnimationFrame(id);
+ }, []);
+
return (
diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts
index caf9d41..61a6fd9 100644
--- a/src/hooks/useInvitations.ts
+++ b/src/hooks/useInvitations.ts
@@ -32,8 +32,8 @@ export interface Invitation {
roleIds: string[];
targetUsername: string;
email?: string;
- createdBy: string;
- createdAt: number;
+ sysCreatedBy: string;
+ sysCreatedAt: number;
expiresAt: number;
usedBy?: string;
usedAt?: number;
diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts
index 6b56420..9444acd 100644
--- a/src/hooks/useMandates.ts
+++ b/src/hooks/useMandates.ts
@@ -24,6 +24,8 @@ import {
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
import { validateMandateName } from '../utils/mandateNameUtils';
+import { resolveColumnTypes } from '../utils/columnTypeResolver';
+import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
// Re-export types
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
@@ -153,19 +155,21 @@ export function useAdminMandates() {
return await fetchMandateByIdApi(request, mandateId);
}, [request]);
- // Generate columns from attributes (displayField = backend {field}Label for FK columns)
- const columns = attributes.map(attr => ({
- key: attr.name,
- label: attr.label || attr.name,
- type: attr.type as any,
- sortable: attr.sortable !== false,
- filterable: attr.filterable !== false,
- searchable: attr.searchable !== false,
- width: attr.width || 150,
- minWidth: attr.minWidth || 100,
- maxWidth: attr.maxWidth || 400,
- displayField: (attr as any).displayField,
- }));
+ // Generate columns from attributes (types merged via resolveColumnTypes)
+ const columns: ColumnConfig[] = useMemo(() => {
+ const raw = attributes.map(attr => ({
+ key: attr.name,
+ label: attr.label || attr.name,
+ sortable: attr.sortable !== false,
+ filterable: attr.filterable !== false,
+ searchable: attr.searchable !== false,
+ width: attr.width || 150,
+ minWidth: attr.minWidth || 100,
+ maxWidth: attr.maxWidth || 400,
+ displayField: (attr as any).displayField,
+ }));
+ return resolveColumnTypes(raw, attributes);
+ }, [attributes]);
// Create mandate
const handleCreate = useCallback(async (mandateData: Partial ): Promise => {
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx
index d8c19b1..e0923f7 100644
--- a/src/pages/AutomationsDashboardPage.tsx
+++ b/src/pages/AutomationsDashboardPage.tsx
@@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
+import { fetchAttributes } from '../api/attributesApi';
+import type { AttributeDefinition } from '../api/attributesApi';
+import { resolveColumnTypes } from '../utils/columnTypeResolver';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
@@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
const _DashboardTab: React.FC = () => {
const { t } = useLanguage();
+ const { request } = useApiRequest();
const { showError } = useToast();
const [metrics, setMetrics] = useState(null);
@@ -431,6 +435,13 @@ const _DashboardTab: React.FC = () => {
const [paginationMeta, setPaginationMeta] = useState(null);
const [tracingRun, setTracingRun] = useState(null);
const lastPaginationParamsRef = useRef(null);
+ const [backendAttributes, setBackendAttributes] = useState([]);
+
+ useEffect(() => {
+ fetchAttributes(request, 'AutoRun')
+ .then(setBackendAttributes)
+ .catch(() => {});
+ }, [request]);
const _loadMetrics = useCallback(async () => {
try {
@@ -529,11 +540,10 @@ const _DashboardTab: React.FC = () => {
stopped: t('Gestoppt'),
}), [t]);
- const _runColumns: ColumnConfig[] = useMemo(() => [
+ const _rawRunColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
label: t('Workflow'),
- type: 'string',
width: 200,
sortable: true,
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
@@ -541,7 +551,6 @@ const _DashboardTab: React.FC = () => {
{
key: 'mandateId',
label: t('Mandant'),
- type: 'string',
width: 140,
sortable: true,
filterable: true,
@@ -550,7 +559,6 @@ const _DashboardTab: React.FC = () => {
{
key: 'featureInstanceId',
label: t('Instanz'),
- type: 'string',
width: 140,
sortable: true,
filterable: true,
@@ -559,7 +567,6 @@ const _DashboardTab: React.FC = () => {
{
key: 'status',
label: t('Status'),
- type: 'string',
width: 110,
sortable: true,
filterable: true,
@@ -574,7 +581,6 @@ const _DashboardTab: React.FC = () => {
{
key: 'sysCreatedAt',
label: t('Gestartet'),
- type: 'number',
width: 150,
sortable: true,
formatter: (v: number) => _formatTs(v),
@@ -582,13 +588,17 @@ const _DashboardTab: React.FC = () => {
{
key: 'sysModifiedAt',
label: t('Beendet'),
- type: 'number',
width: 150,
sortable: true,
formatter: (v: number) => _formatTs(v),
},
], [t, _STATUS_LABELS]);
+ const _runColumns = useMemo(
+ () => resolveColumnTypes(_rawRunColumns, backendAttributes),
+ [_rawRunColumns, backendAttributes],
+ );
+
const _hookData = useMemo(() => ({
refetch: _loadRuns,
pagination: paginationMeta,
@@ -711,6 +721,13 @@ const _WorkflowsTab: React.FC = () => {
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState(null);
const lastPaginationParamsRef = useRef(null);
+ const [backendAttributes, setBackendAttributes] = useState([]);
+
+ useEffect(() => {
+ fetchAttributes(request, 'Automation2Workflow')
+ .then(setBackendAttributes)
+ .catch(() => {});
+ }, [request]);
const _load = useCallback(async (paginationParams?: any) => {
if (paginationParams !== undefined) {
@@ -883,12 +900,11 @@ const _WorkflowsTab: React.FC = () => {
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
- const _columns: ColumnConfig[] = useMemo(() => [
- { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
{
key: 'mandateId',
label: t('Mandant'),
- type: 'string',
width: 140,
sortable: true,
filterable: true,
@@ -897,7 +913,6 @@ const _WorkflowsTab: React.FC = () => {
{
key: 'featureInstanceId',
label: t('Instanz'),
- type: 'string',
width: 140,
sortable: true,
filterable: true,
@@ -906,7 +921,6 @@ const _WorkflowsTab: React.FC = () => {
{
key: 'active',
label: t('Aktiv'),
- type: 'boolean',
width: 80,
sortable: true,
filterable: true,
@@ -914,13 +928,11 @@ const _WorkflowsTab: React.FC = () => {
{
key: 'isRunning',
label: t('Läuft'),
- type: 'boolean',
width: 80,
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
- type: 'number',
width: 140,
sortable: true,
formatter: (v: number) => _formatTs(v),
@@ -928,19 +940,22 @@ const _WorkflowsTab: React.FC = () => {
{
key: 'lastStartedAt',
label: t('Zuletzt gestartet'),
- type: 'number',
width: 160,
formatter: (v: number) => _formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
- type: 'number',
width: 80,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
+ const _columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
const _hookData = useMemo(() => ({
refetch: _load,
handleDelete: (id: string) => _handleDelete(id),
diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx
index 7d70bce..ea45645 100644
--- a/src/pages/ComplianceAuditPage.tsx
+++ b/src/pages/ComplianceAuditPage.tsx
@@ -14,6 +14,10 @@ import {
} from 'recharts';
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
import api from '../api';
+import { useApiRequest } from '../hooks/useApi';
+import { fetchAttributes } from '../api/attributesApi';
+import type { AttributeDefinition } from '../api/attributesApi';
+import { resolveColumnTypes } from '../utils/columnTypeResolver';
import { useLanguage } from '../providers/language/LanguageContext';
import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm';
@@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100;
export const ComplianceAuditPage: React.FC = () => {
const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const [aiAuditAttrs, setAiAuditAttrs] = useState([]);
+ const [auditLogAttrs, setAuditLogAttrs] = useState([]);
+ const [neutAttrs, setNeutAttrs] = useState([]);
const { fetchMandates } = useUserMandates();
const { confirm, ConfirmDialog } = useConfirm();
+ useEffect(() => {
+ fetchAttributes(request, 'AiAuditLogEntry').then(setAiAuditAttrs).catch(() => setAiAuditAttrs([]));
+ fetchAttributes(request, 'AuditLogEntry').then(setAuditLogAttrs).catch(() => setAuditLogAttrs([]));
+ fetchAttributes(request, 'DataNeutralizerAttributesView').then(setNeutAttrs).catch(() => setNeutAttrs([]));
+ }, [request]);
+
const [mandates, setMandates] = useState([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedMandateId, setSelectedMandateId] = useState(null);
@@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Column definitions ──
- const aiLogColumns: ColumnConfig[] = useMemo(() => [
- { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
+ const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
{
- key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
- formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
+ key: 'username',
+ label: t('Benutzer'),
+ sortable: true,
+ searchable: true,
+ width: 140,
+ formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
},
{
- key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
+ key: 'instanceLabel',
+ label: t('Feature-Instanz'),
+ sortable: true,
+ filterable: true,
+ width: 160,
formatter: (val: any, row: any) => val || row?.featureCode || '–',
},
- { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
+ { key: 'aiModel', label: t('AI-Modell'), sortable: true, filterable: true, width: 160 },
{
- key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
+ key: 'aiProvider',
+ label: t('Provider / Typ'),
+ sortable: true,
+ filterable: true,
+ width: 140,
formatter: (val: any, row: any) => {
const provider = val || '–';
const op = row?.operationType;
@@ -453,63 +479,110 @@ export const ComplianceAuditPage: React.FC = () => {
},
},
{
- key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110,
- formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–',
+ key: 'priceCHF',
+ label: t('Kosten (CHF)'),
+ sortable: true,
+ width: 110,
+ formatter: (val: any) => (val != null ? Number(val).toFixed(4) : '–'),
},
{
- key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100,
- formatter: (val: any) => val ? '✓' : '–',
+ key: 'neutralizationActive',
+ label: t('Neutralisierung'),
+ sortable: true,
+ width: 100,
+ formatter: (val: any) => (val ? '✓' : '–'),
},
{
- key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80,
- formatter: (val: any) => val ? t('OK') : t('Fehler'),
- cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
+ key: 'success',
+ label: t('Status'),
+ sortable: true,
+ filterable: true,
+ width: 80,
+ formatter: (val: any) => (val ? t('OK') : t('Fehler')),
+ cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
},
], [t]);
- const auditLogColumns: ColumnConfig[] = useMemo(() => [
- { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
+ const aiLogColumns: ColumnConfig[] = useMemo(
+ () => resolveColumnTypes(_rawAiLogColumns, aiAuditAttrs),
+ [_rawAiLogColumns, aiAuditAttrs],
+ );
+
+ const _rawAuditLogColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
{
- key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
- formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
+ key: 'username',
+ label: t('Benutzer'),
+ sortable: true,
+ searchable: true,
+ width: 140,
+ formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
},
{
- key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110,
+ key: 'category',
+ label: t('Kategorie'),
+ sortable: true,
+ filterable: true,
+ width: 110,
cellClassName: (val: any) => {
const color = _CATEGORY_COLORS[val as string];
return color ? styles[`cat_${val}`] || '' : '';
},
formatter: (val: any) => val || '–',
},
- { key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 },
- { key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
- { key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 },
+ { key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 },
+ { key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 },
+ { key: 'details', label: t('Details'), searchable: true, width: 250 },
{
- key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70,
- formatter: (val: any) => val ? '✓' : '✗',
- cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
+ key: 'success',
+ label: t('Status'),
+ sortable: true,
+ width: 70,
+ formatter: (val: any) => (val ? '✓' : '✗'),
+ cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
},
- { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 },
+ { key: 'ipAddress', label: t('IP'), width: 120 },
], [t]);
- const neutColumns: ColumnConfig[] = useMemo(() => [
- { key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
- { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
- { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
+ const auditLogColumns: ColumnConfig[] = useMemo(
+ () => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs),
+ [_rawAuditLogColumns, auditLogAttrs],
+ );
+
+ const _rawNeutColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'placeholder', label: t('Platzhalter'), sortable: true, searchable: true, width: 220 },
+ { key: 'originalText', label: t('Originaltext'), sortable: true, searchable: true, width: 240 },
+ { key: 'patternType', label: t('Kategorie'), sortable: true, filterable: true, width: 120 },
{
- key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
- formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
+ key: 'username',
+ label: t('Benutzer'),
+ sortable: true,
+ filterable: true,
+ width: 140,
+ formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
},
{
- key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
- formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
+ key: 'instanceLabel',
+ label: t('Feature-Instanz'),
+ sortable: true,
+ filterable: true,
+ width: 160,
+ formatter: (val: any, row: any) => val || (row?.featureInstanceId ? `NA(${row.featureInstanceId})` : '–'),
},
{
- key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
- formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
+ key: 'fileId',
+ label: t('Datei'),
+ sortable: true,
+ width: 140,
+ formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'),
},
], [t]);
+ const neutColumns: ColumnConfig[] = useMemo(
+ () => resolveColumnTypes(_rawNeutColumns, neutAttrs),
+ [_rawNeutColumns, neutAttrs],
+ );
+
// ── fetchFilterValues for autofilter dropdowns ──
const _makeFetchFilterValues = useCallback(
diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx
index 4fbd307..7ac9bab 100644
--- a/src/pages/admin/AdminFeatureAccessPage.tsx
+++ b/src/pages/admin/AdminFeatureAccessPage.tsx
@@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo
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';
@@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
const { loadFeatures } = useFeatureStore();
+ const { request } = useApiRequest();
// State
const [mandates, setMandates] = useState([]);
@@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
}, [selectedMandateId, fetchInstances]);
- // Table columns
- const columns = useMemo(() => [
- { key: 'label', label: t('Name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
- { key: 'featureCode', label: t('Feature'), type: 'string' as const, sortable: true, filterable: true, width: 150,
- render: (value: string) => {
+ 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);
- return feature ? (feature.label || value) : value;
- }
+ const label = feature ? (feature.label || value) : value;
+ return label;
+ },
},
- { key: 'enabled', label: t('Aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
+ { 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(() => {
diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
index b91937d..0fc90f6 100644
--- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
+++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
@@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useFeatureStore } from '../../stores/featureStore';
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 styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
const { loadFeatures } = useFeatureStore();
+ const { request } = useApiRequest();
+ const [backendAttributes, setBackendAttributes] = useState([]);
// Combined instance option type
interface CombinedInstanceOption {
@@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
return selectedCombinedKey.split(':')[1] || '';
}, [selectedCombinedKey]);
+ useEffect(() => {
+ fetchAttributes(request, 'FeatureAccessView')
+ .then(setBackendAttributes)
+ .catch(() => setBackendAttributes([]));
+ }, [request]);
+
// Load mandates and features on mount, then build combined options
useEffect(() => {
fetchFeatures();
@@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
return allUsers.filter(u => !existingUserIds.has(u.id));
}, [allUsers, instanceUsers]);
- // Table columns
- const columns = useMemo(() => [
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'username',
label: t('Benutzername'),
- type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
@@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{
key: 'email',
label: t('E-Mail'),
- type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
@@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{
key: 'fullName',
label: t('Vollständiger Name'),
- type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
@@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{
key: 'roleLabels',
label: t('Rollen'),
- type: 'text' as const,
sortable: false,
filterable: false,
searchable: true,
width: 200,
- render: (value: string[]) => {
+ formatter: (value: string[]) => {
if (!value || value.length === 0) return '-';
return value.join(', ');
},
@@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{
key: 'enabled',
label: t('Aktiv'),
- type: 'boolean' as const,
sortable: true,
filterable: true,
searchable: false,
@@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
},
], [t]);
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
availableUsers.map(u => ({
diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx
index 48227b5..c1259ac 100644
--- a/src/pages/admin/AdminFeatureRolesPage.tsx
+++ b/src/pages/admin/AdminFeatureRolesPage.tsx
@@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules';
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } 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 styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
+ const { request } = useApiRequest();
+ const [roleTableAttributes, setRoleTableAttributes] = useState([]);
+
// State
const [features, setFeatures] = useState([]);
const [selectedFeatureCode, setSelectedFeatureCode] = useState('');
@@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [permissionsRole, setPermissionsRole] = useState(null);
+ useEffect(() => {
+ fetchAttributes(request, 'Role')
+ .then(setRoleTableAttributes)
+ .catch(() => setRoleTableAttributes([]));
+ }, [request]);
+
// Load features on mount
useEffect(() => {
const loadFeatures = async () => {
@@ -130,40 +143,41 @@ export const AdminFeatureRolesPage: React.FC = () => {
return String(value);
};
- // Table columns
- const columns = useMemo(() => [
- {
- key: 'roleLabel',
- label: t('Rollen-Label'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
- searchable: true,
- width: 180
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ {
+ key: 'roleLabel',
+ label: t('Rollen-Label'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
+ width: 180,
},
- {
- key: 'description',
- label: t('Beschreibung'),
- type: 'string' as const,
- sortable: false,
+ {
+ key: 'description',
+ label: t('Beschreibung'),
+ sortable: false,
width: 300,
- formatter: (value: string) => getTextValue(value)
+ formatter: (value: string) => getTextValue(value),
},
- {
- key: 'featureCode',
- label: t('Feature'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
+ {
+ key: 'featureCode',
+ label: t('Feature'),
+ sortable: true,
+ filterable: true,
width: 120,
formatter: (value: string) => (
{value}
- )
+ ),
},
], [t]);
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, roleTableAttributes),
+ [_rawColumns, roleTableAttributes],
+ );
+
// Form attributes for create
const createFields: AttributeDefinition[] = useMemo(() => {
const fields: AttributeDefinition[] = [
diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx
index 2f76da0..61c64f8 100644
--- a/src/pages/admin/AdminInvitationsPage.tsx
+++ b/src/pages/admin/AdminInvitationsPage.tsx
@@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } 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 styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
+ const { request } = useApiRequest();
const {
invitations,
loading,
@@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => {
}
};
loadMandates();
- // Fetch Invitation attributes from backend
- api.get('/api/attributes/Invitation').then(response => {
- const attrs = response.data?.attributes || response.data || [];
- setBackendAttributes(Array.isArray(attrs) ? attrs : []);
- }).catch(() => setBackendAttributes([]));
- }, [fetchMandates]);
+ fetchAttributes(request, 'Invitation')
+ .then(setBackendAttributes)
+ .catch(() => setBackendAttributes([]));
+ }, [fetchMandates, request]);
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
useEffect(() => {
@@ -84,25 +86,22 @@ export const AdminInvitationsPage: React.FC = () => {
});
};
- // Table columns
- const columns = useMemo(() => [
- {
- key: 'targetUsername',
- label: t('Benutzername'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
- searchable: true,
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ {
+ key: 'targetUsername',
+ label: t('Benutzername'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
width: 150,
},
- {
- key: 'email',
- label: t('E-Mail'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
+ {
+ key: 'email',
+ label: t('E-Mail'),
+ sortable: true,
+ filterable: true,
width: 180,
- render: (value: string, row: Invitation) => {
+ formatter: (value: string, row: Invitation) => {
const emailText = value || '-';
const emailSent = (row as any).emailSent;
return (
@@ -110,30 +109,28 @@ export const AdminInvitationsPage: React.FC = () => {
{emailText} {emailSent && '✓'}
);
- }
+ },
},
- {
- key: 'roleIds',
- label: t('Rollen'),
- type: 'string', // Array rendered as string
- sortable: false,
- filterable: false,
+ {
+ key: 'roleIds',
+ label: t('Rollen'),
+ sortable: false,
+ filterable: false,
width: 150,
- render: (value: string[]) => {
+ formatter: (value: string[]) => {
if (!value || value.length === 0) return '-';
- return value.map(roleId => {
+ return value.map((roleId) => {
const role = roles.find(r => r.id === roleId);
return role?.roleLabel || roleId;
}).join(', ');
- }
- } as any,
- {
- key: 'expiresAt',
- label: t('Gültig bis'),
- type: 'number' as const,
- sortable: true,
+ },
+ },
+ {
+ key: 'expiresAt',
+ label: t('Gültig bis'),
+ sortable: true,
width: 150,
- render: (value: number) => {
+ formatter: (value: number) => {
const text = formatDate(value);
const isExpired = value < Date.now() / 1000;
return (
@@ -141,29 +138,32 @@ export const AdminInvitationsPage: React.FC = () => {
{text} {isExpired && '(abgelaufen)'}
);
- }
+ },
},
- {
- key: 'currentUses',
- label: t('Verwendet'),
- type: 'string' as const,
- sortable: true,
+ {
+ key: 'currentUses',
+ label: t('Verwendet'),
+ sortable: true,
width: 100,
- render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
+ formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
},
- {
- key: 'createdAt',
- label: t('Erstellt'),
- type: 'number' as const,
- sortable: true,
+ {
+ key: 'sysCreatedAt',
+ label: t('Erstellt'),
+ sortable: true,
width: 150,
- render: (value: number) => formatDate(value)
+ formatter: (value: number) => formatDate(value),
},
], [roles, t]);
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
const createFields: AttributeDefinition[] = useMemo(() => {
- const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
+ const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
// Mandate-level roles (user, viewer, admin) - same as when adding mandate members
const roleOptions = roles
diff --git a/src/pages/admin/AdminLanguagesPage.tsx b/src/pages/admin/AdminLanguagesPage.tsx
index 5e74919..ea77f94 100644
--- a/src/pages/admin/AdminLanguagesPage.tsx
+++ b/src/pages/admin/AdminLanguagesPage.tsx
@@ -8,6 +8,10 @@ import api from '../../api';
import axios from 'axios';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
import { useConfirm } from '../../hooks/useConfirm';
+import { useApiRequest } from '../../hooks/useApi';
+import { fetchAttributes } from '../../api/attributesApi';
+import type { AttributeDefinition } from '../../api/attributesApi';
+import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './Admin.module.css';
@@ -39,44 +43,6 @@ type ProgressInfo = {
keysTranslated?: number;
};
-function _getColumns(t: (key: string) => string): ColumnConfig[] {
- return [
- { key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 },
- { key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
- {
- key: 'status',
- label: t('Status'),
- type: 'text',
- sortable: true,
- filterable: true,
- width: 160,
- formatter: (_val: any, row: any) => {
- const r = row as LangRow;
- if (r.updating) {
- return (
-
-
- {t('wird aktualisiert…')}
-
- );
- }
- if (r.status === 'generating') {
- return (
-
-
- {t('wird erzeugt…')}
-
- );
- }
- return r.status;
- },
- },
- { key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 },
- { key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 },
- { key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 },
- ];
-}
-
// ISO 639 catalog (codes + native labels + priority order) is provided by the
// gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here --
// any divergence between frontend and backend caused subtle bugs (e.g. user
@@ -278,6 +244,8 @@ const _ProgressOverlay: React.FC<{
export const AdminLanguagesPage: React.FC = () => {
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm();
+ const { request } = useApiRequest();
+ const [langSetAttributes, setLangSetAttributes] = useState([]);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -288,6 +256,12 @@ export const AdminLanguagesPage: React.FC = () => {
const busyRef = useRef(false);
const abortRef = useRef(null);
+ useEffect(() => {
+ fetchAttributes(request, 'UiLanguageSetView')
+ .then(setLangSetAttributes)
+ .catch(() => setLangSetAttributes([]));
+ }, [request]);
+
useEffect(() => {
let cancelled = false;
(async () => {
@@ -393,6 +367,44 @@ export const AdminLanguagesPage: React.FC = () => {
});
}, [rows, search]);
+ const columns = useMemo(() => {
+ const raw: ColumnConfig[] = [
+ { key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
+ { key: 'label', label: t('Bezeichnung'), sortable: true, filterable: true, width: 200 },
+ {
+ key: 'status',
+ label: t('Status'),
+ sortable: true,
+ filterable: true,
+ width: 160,
+ formatter: (_val: any, row: any) => {
+ const r = row as LangRow;
+ if (r.updating) {
+ return (
+
+ | |