>({
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 (
+
+
+ {t('wird aktualisiert…')}
+
+ );
+ }
+ if (r.status === 'generating') {
+ return (
+
+
+ {t('wird erzeugt…')}
+
+ );
+ }
+ return r.status;
+ },
+ },
+ { key: 'uiCount', label: t('UI'), sortable: true, width: 80 },
+ { key: 'gatewayCount', label: t('API'), sortable: true, width: 80 },
+ { key: 'entriesCount', label: t('Gesamt'), sortable: true, width: 80 },
+ ];
+ return resolveColumnTypes(raw, langSetAttributes);
+ }, [t, langSetAttributes]);
+
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
const addChoices = useMemo(() => {
@@ -869,7 +881,7 @@ export const AdminLanguagesPage: React.FC = () => {
{
const { t, currentLanguage } = useLanguage();
const navigate = useNavigate();
+ const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const {
roles,
@@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => {
}
};
loadMandates();
- // Fetch Role attributes from backend
- api.get('/api/attributes/Role').then(response => {
- const attrs = response.data?.attributes || response.data || [];
- setBackendAttributes(Array.isArray(attrs) ? attrs : []);
- }).catch(() => setBackendAttributes([]));
- }, [fetchMandates]);
+ fetchAttributes(request, 'Role')
+ .then(setBackendAttributes)
+ .catch(() => setBackendAttributes([]));
+ }, [fetchMandates, request]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
@@ -102,32 +104,28 @@ export const AdminMandateRolesPage: React.FC = () => {
return String(desc);
};
- // Table columns - scopeType is now a backend-computed field
- const columns = useMemo(() => [
- {
- key: 'roleLabel',
- label: t('Bezeichnung'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
- searchable: true,
- width: 150
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ {
+ key: 'roleLabel',
+ label: t('Bezeichnung'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
+ width: 150,
},
- {
- key: 'description',
- label: t('Beschreibung'),
- type: 'string' as const,
- sortable: false,
+ {
+ key: 'description',
+ label: t('Beschreibung'),
+ sortable: false,
filterable: false,
width: 250,
- formatter: (value: string) => getDescriptionText(value)
+ formatter: (value: string) => getDescriptionText(value),
},
- {
- key: 'scopeType',
- label: t('Geltungsbereich'),
- type: 'string' as const,
- sortable: true,
- filterable: true,
+ {
+ key: 'scopeType',
+ label: t('Geltungsbereich'),
+ sortable: true,
+ filterable: true,
width: 140,
formatter: (value: string) => {
if (value === 'system') {
@@ -149,10 +147,15 @@ export const AdminMandateRolesPage: React.FC = () => {
{t('Mandant')}
);
- }
+ },
},
], [t]);
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
// Form attributes from backend - for create form
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx
index 2ff9715..8d9f5fa 100644
--- a/src/pages/admin/AdminUserMandatesPage.tsx
+++ b/src/pages/admin/AdminUserMandatesPage.tsx
@@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding } 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';
@@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
+ const { request } = useApiRequest();
const {
users,
loading,
@@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => {
}
};
loadMandates();
- // Fetch UserMandate attributes from backend (for table columns)
- api.get('/api/attributes/UserMandate').then(response => {
- const attrs = response.data?.attributes || response.data || [];
- setBackendAttributes(Array.isArray(attrs) ? attrs : []);
- }).catch(() => setBackendAttributes([]));
- }, [fetchMandates]);
+ fetchAttributes(request, 'UserMandateView')
+ .then(setBackendAttributes)
+ .catch(() => setBackendAttributes([]));
+ }, [fetchMandates, request]);
// Load users when mandate changes
useEffect(() => {
@@ -97,60 +99,57 @@ export const AdminUserMandatesPage: React.FC = () => {
return allUsers.filter(u => !existingUserIds.has(u.id));
}, [allUsers, users]);
- // Table columns - based on MandateUserInfo response structure
- const columns = useMemo(() => {
- return [
- {
- key: 'username',
- label: t('Benutzername'),
- type: 'text' as any,
- sortable: true,
- filterable: true,
- searchable: true,
- width: 150,
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ {
+ key: 'username',
+ label: t('Benutzername'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
+ width: 150,
+ },
+ {
+ key: 'email',
+ label: t('E-Mail'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
+ width: 200,
+ },
+ {
+ key: 'fullName',
+ label: t('Vollständiger Name'),
+ sortable: true,
+ filterable: true,
+ searchable: true,
+ width: 180,
+ },
+ {
+ key: 'roleLabels',
+ label: t('Rollen'),
+ sortable: false,
+ filterable: false,
+ searchable: true,
+ width: 200,
+ formatter: (value: string[]) => {
+ if (!value || value.length === 0) return '-';
+ return value.join(', ');
},
- {
- key: 'email',
- label: t('E-Mail'),
- type: 'text' as any,
- sortable: true,
- filterable: true,
- searchable: true,
- width: 200,
- },
- {
- key: 'fullName',
- label: t('Vollständiger Name'),
- type: 'text' as any,
- sortable: true,
- filterable: true,
- searchable: true,
- width: 180,
- },
- {
- key: 'roleLabels',
- label: t('Rollen'),
- type: 'text' as any,
- sortable: false,
- filterable: false,
- searchable: true,
- width: 200,
- render: (value: string[]) => {
- if (!value || value.length === 0) return '-';
- return value.join(', ');
- },
- },
- {
- key: 'enabled',
- label: t('Aktiv'),
- type: 'boolean' as any,
- sortable: true,
- filterable: true,
- searchable: false,
- width: 80,
- },
- ];
- }, [t]);
+ },
+ {
+ key: 'enabled',
+ label: t('Aktiv'),
+ sortable: true,
+ filterable: true,
+ searchable: false,
+ width: 80,
+ },
+ ], [t]);
+
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx
index fcac187..20f350d 100644
--- a/src/pages/admin/AdminUsersPage.tsx
+++ b/src/pages/admin/AdminUsersPage.tsx
@@ -14,6 +14,7 @@ import styles from './Admin.module.css';
import { getUserDataCache } from '../../utils/userCache';
import { useLanguage } from '../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../utils/columnTypeResolver';
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
@@ -57,12 +58,11 @@ export const AdminUsersPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
- // Generate columns from attributes
+ // Generate columns from attributes; types from backend via resolveColumnTypes
const columns = useMemo(() => {
- return (attributes || []).map(attr => ({
+ const raw = (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,
@@ -71,6 +71,7 @@ export const AdminUsersPage: React.FC = () => {
maxWidth: attr.maxWidth || 400,
displayField: (attr as any).displayField,
}));
+ return resolveColumnTypes(raw, attributes || []);
}, [attributes]);
// Check permissions
diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx
index 774b7e6..9636e82 100644
--- a/src/pages/basedata/ConnectionsPage.tsx
+++ b/src/pages/basedata/ConnectionsPage.tsx
@@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../utils/columnTypeResolver';
export const ConnectionsPage: React.FC = () => {
const { t } = useLanguage();
@@ -54,13 +55,12 @@ export const ConnectionsPage: React.FC = () => {
const columns = useMemo(() => {
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
- return (attributes || [])
+ const raw = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => {
const col: any = {
key: attr.name,
- label: attr.label || attr.name,
- type: attr.type as any,
+ label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
@@ -71,13 +71,9 @@ export const ConnectionsPage: React.FC = () => {
frontendFormat: (attr as any).frontendFormat,
frontendFormatLabels: (attr as any).frontendFormatLabels,
};
-
- if (attr.name === 'userId') {
- col.label = t('Benutzer');
- }
-
return col;
});
+ return resolveColumnTypes(raw, attributes || []);
}, [attributes, t]);
// Check permissions
diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx
index b7b4fe4..a65ad5b 100644
--- a/src/pages/basedata/FilesPage.tsx
+++ b/src/pages/basedata/FilesPage.tsx
@@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt';
import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { getUserDataCache } from '../../utils/userCache';
+import { resolveColumnTypes } from '../../utils/columnTypeResolver';
interface UserFile {
id: string;
@@ -203,7 +204,6 @@ export const FilesPage: React.FC = () => {
.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,
@@ -217,7 +217,6 @@ export const FilesPage: React.FC = () => {
cols.push({
key: 'sysCreatedBy',
label: t('Erstellt von'),
- type: 'text' as any,
sortable: true,
filterable: true,
searchable: true,
@@ -226,7 +225,7 @@ export const FilesPage: React.FC = () => {
maxWidth: 250,
displayField: 'sysCreatedByLabel',
} as any);
- return cols;
+ return resolveColumnTypes(cols, attributes || []);
}, [attributes, t]);
const canCreate = permissions?.create !== 'n';
diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx
index e274393..3fa1bdf 100644
--- a/src/pages/basedata/PromptsPage.tsx
+++ b/src/pages/basedata/PromptsPage.tsx
@@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa';
import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../utils/columnTypeResolver';
interface Prompt {
id: string;
@@ -76,7 +77,6 @@ export const PromptsPage: React.FC = () => {
.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,
@@ -92,7 +92,6 @@ export const PromptsPage: React.FC = () => {
cols.push({
key: 'sysCreatedBy',
label: t('Erstellt von'),
- type: 'text' as any,
sortable: true,
filterable: true,
searchable: true,
@@ -104,7 +103,7 @@ export const PromptsPage: React.FC = () => {
frontendFormatLabels: undefined,
});
- return cols;
+ return resolveColumnTypes(cols, attributes || []);
}, [attributes, t]);
// Check permissions
diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx
index dc2a81c..f6d7403 100644
--- a/src/pages/billing/AdminSubscriptionsPage.tsx
+++ b/src/pages/billing/AdminSubscriptionsPage.tsx
@@ -1,7 +1,11 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
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 api from '../../api';
import styles from './Billing.module.css';
@@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext';
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
-function _getColumns(t: (key: string) => string): ColumnConfig[] {
- return [
- { key: 'mandateName', label: t('Mandant'), type: 'text', sortable: true, filterable: true, width: 180 },
- { key: 'planTitle', label: t('Plan'), type: 'text', sortable: true, filterable: true, width: 180 },
- { key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 110 },
- { key: 'recurring', label: t('Wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 },
- { key: 'activeUsers', label: t('Benutzer'), type: 'number', sortable: true, width: 70 },
- { key: 'activeInstances', label: t('Module'), type: 'number', sortable: true, width: 90 },
- { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), type: 'number', sortable: true, width: 140 },
- { key: 'startedAt', label: t('Gestartet'), type: 'date', sortable: true, filterable: true, width: 130 },
- { key: 'currentPeriodEnd', label: t('Periodenende'), type: 'date', sortable: true, filterable: true, width: 130 },
- { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), type: 'number', sortable: true, width: 100 },
- { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), type: 'number', sortable: true, width: 110 },
- ];
-}
-
const AdminSubscriptionsPage: React.FC = () => {
- const { t } = useLanguage();
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const [backendAttributes, setBackendAttributes] = useState([]);
const { confirm, ConfirmDialog } = useConfirm();
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
+ useEffect(() => {
+ fetchAttributes(request, 'MandateSubscriptionView')
+ .then(setBackendAttributes)
+ .catch(() => setBackendAttributes([]));
+ }, [request]);
+
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
+ { key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 },
+ { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
+ { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
+ { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
+ { key: 'activeInstances', label: t('Module'), sortable: true, width: 90 },
+ { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), sortable: true, width: 140 },
+ { key: 'startedAt', label: t('Gestartet'), sortable: true, filterable: true, width: 130 },
+ { key: 'currentPeriodEnd', label: t('Periodenende'), sortable: true, filterable: true, width: 130 },
+ { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), sortable: true, width: 100 },
+ { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), sortable: true, width: 110 },
+ ], [t]);
+
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
const _handleForceCancel = useCallback(async (row: any) => {
const ok = await confirm(
t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }),
@@ -44,7 +59,7 @@ const AdminSubscriptionsPage: React.FC = () => {
} catch (err) {
console.error('Force cancel failed:', err);
}
- }, [confirm, refetch]);
+ }, [confirm, refetch, t]);
return (
@@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
{
const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const [billingTxnAttributes, setBillingTxnAttributes] = useState([]);
const [activeTab, setActiveTab] = useState('overview');
const [searchParams, setSearchParams] = useSearchParams();
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -338,6 +344,12 @@ export const BillingDataView: React.FC = () => {
const [transactionsError, setTransactionsError] = useState(null);
const [transactionsPagination, setTransactionsPagination] = useState(null);
+ useEffect(() => {
+ fetchAttributes(request, 'BillingTransactionView')
+ .then(setBillingTxnAttributes)
+ .catch(() => setBillingTxnAttributes([]));
+ }, [request]);
+
// Unified scope params -- single source of truth for all tab API calls
// "nur meine Daten" is an additional filter on top of the dropdown scope
const _scopeParams = useMemo((): Record => {
@@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => {
fetchFilterValues: _fetchTransactionFilterValues,
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
- // Table column definitions
- const columns: ColumnConfig[] = useMemo(() => [
- { key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 },
- { key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
- { key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
- { key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 },
- { key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 },
- { key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
- { key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
- { key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
- { key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
+ const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
+ { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 },
+ { key: 'userName', label: t('Benutzer'), sortable: true, filterable: true, searchable: true, width: 150 },
+ { key: 'transactionType', label: t('Typ'), sortable: true, filterable: true, width: 100 },
+ { key: 'description', label: t('Beschreibung'), searchable: true, width: 250 },
+ { key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 },
+ { key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 },
+ { key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 },
+ { key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 },
], [t]);
+ const columns: ColumnConfig[] = useMemo(
+ () => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes),
+ [_rawTransactionColumns, billingTxnAttributes],
+ );
+
const totalBalance = useMemo(() => {
const filtered = selectedScope === 'personal' || selectedScope === 'all'
? balances
diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx
index bf2b575..db9c174 100644
--- a/src/pages/billing/BillingMandateView.tsx
+++ b/src/pages/billing/BillingMandateView.tsx
@@ -145,7 +145,7 @@ const TransactionTable: React.FC = ({ transactions }) =>
{transactions.map((txn) => (
- | {formatDate(txn.createdAt)} |
+ {formatDate(txn.sysCreatedAt)} |
{txn.mandateName || '-'} |
diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx
index 533513f..b176ff8 100644
--- a/src/pages/billing/BillingTransactions.tsx
+++ b/src/pages/billing/BillingTransactions.tsx
@@ -58,7 +58,7 @@ const TransactionRow: React.FC = ({ transaction }) => {
return (
- | {formatDate(transaction.createdAt)} |
+ {formatDate(transaction.sysCreatedAt)} |
{transaction.mandateName || '-'} |
diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx
index 644f2ef..3e9abb1 100644
--- a/src/pages/billing/BillingUserView.tsx
+++ b/src/pages/billing/BillingUserView.tsx
@@ -239,7 +239,7 @@ const UserTransactionTable: React.FC = ({
{filteredTransactions.map((txn) => (
- | {formatDate(txn.createdAt)} |
+ {formatDate(txn.sysCreatedAt)} |
{txn.mandateName || '-'} |
{txn.userName || '-'} |
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
index 9737eab..06dd2e5 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
@@ -22,6 +22,9 @@ import {
type AutoWorkflowTemplate,
type AutoTemplateScope,
} from '../../../api/workflowApi';
+import { fetchAttributes } from '../../../api/attributesApi';
+import type { AttributeDefinition } from '../../../api/attributesApi';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
@@ -68,6 +71,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
const [sharingId, setSharingId] = useState(null);
const [paginationMeta, setPaginationMeta] = useState(null);
+ const [backendAttributes, setBackendAttributes] = useState([]);
+
+ useEffect(() => {
+ fetchAttributes(request, 'AutoWorkflow')
+ .then(setBackendAttributes)
+ .catch(() => {});
+ }, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
@@ -173,20 +183,18 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
[mandateId, instanceId, navigate]
);
- const columns: ColumnConfig[] = useMemo(
+ const _rawColumns: ColumnConfig[] = useMemo(
() => [
- { key: 'label', label: t('Vorlage'), type: 'string', width: 220, sortable: true },
+ { key: 'label', label: t('Vorlage'), width: 220, sortable: true },
{
key: 'templateScope',
label: t('Bereich'),
- type: 'string',
width: 100,
formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
},
{
key: 'sharedReadOnly',
label: t('Freigegeben'),
- type: 'boolean',
width: 100,
formatter: (v: boolean) =>
v ? (
@@ -198,14 +206,12 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
{
key: 'sysCreatedBy',
label: t('Erstellt von'),
- type: 'string',
width: 140,
displayField: 'sysCreatedByLabel',
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
- type: 'number',
width: 140,
formatter: (v: number) => _formatTs(v),
},
@@ -213,6 +219,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
[t, scopeLabels],
);
+ const columns = useMemo(
+ () => resolveColumnTypes(_rawColumns, backendAttributes),
+ [_rawColumns, backendAttributes],
+ );
+
if (!instanceId) {
return (
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
index 4d0c0ad..d8511c1 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
@@ -6,7 +6,7 @@
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
-import React, { useState, useCallback, useEffect, useRef } from 'react';
+import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
@@ -26,6 +26,9 @@ import {
type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
+import { fetchAttributes } from '../../../api/attributesApi';
+import type { AttributeDefinition } from '../../../api/attributesApi';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
@@ -64,6 +67,13 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const [paginationMeta, setPaginationMeta] = useState (null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef(null);
+ const [backendAttributes, setBackendAttributes] = useState([]);
+
+ useEffect(() => {
+ fetchAttributes(request, 'Automation2Workflow')
+ .then(setBackendAttributes)
+ .catch(() => {});
+ }, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
@@ -251,12 +261,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load, t],
);
- const columns: ColumnConfig[] = [
- { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
+ const _rawColumns: ColumnConfig[] = useMemo(() => [
+ { key: 'label', label: t('Workflow'), width: 200, sortable: true },
{
key: 'active',
label: t('Aktiv (Spalte)'),
- type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value !== false ? (
@@ -268,7 +277,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
{
key: 'isRunning',
label: t('läuft'),
- type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value ? (
@@ -280,7 +288,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
- type: 'string',
width: 160,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
@@ -290,25 +297,27 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
{
key: 'createdAt',
label: t('Erstellt'),
- type: 'number',
width: 140,
formatter: (v: number) => formatTs(v),
},
{
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 = {
refetch: load,
diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx
index 80fc0dd..e6d77f8 100644
--- a/src/pages/views/realestate/RealEstateParcelsView.tsx
+++ b/src/pages/views/realestate/RealEstateParcelsView.tsx
@@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
export const RealEstateParcelsView: React.FC = () => {
const { t } = useLanguage();
@@ -54,10 +55,9 @@ export const RealEstateParcelsView: React.FC = () => {
}, [instanceId, refetch]);
const columns = useMemo(() => {
- return (attributes || []).map(attr => ({
+ const raw = (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
- type: attr.type as 'string' | 'number' | 'date' | 'boolean',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
@@ -66,6 +66,7 @@ export const RealEstateParcelsView: React.FC = () => {
maxWidth: attr.maxWidth || 400,
displayField: (attr as any).displayField,
}));
+ return resolveColumnTypes(raw, attributes || []);
}, [attributes]);
const canCreate = permissions?.create !== 'n';
diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx
index 4d25a4b..9a92bc0 100644
--- a/src/pages/views/realestate/RealEstateProjectsView.tsx
+++ b/src/pages/views/realestate/RealEstateProjectsView.tsx
@@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
export const RealEstateProjectsView: React.FC = () => {
const { t } = useLanguage();
@@ -52,10 +53,9 @@ export const RealEstateProjectsView: React.FC = () => {
}, [instanceId, refetch]);
const columns = useMemo(() => {
- return (attributes || []).map(attr => ({
+ const raw = (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
- type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
@@ -64,6 +64,7 @@ export const RealEstateProjectsView: React.FC = () => {
maxWidth: attr.maxWidth || 400,
displayField: (attr as any).displayField,
}));
+ return resolveColumnTypes(raw, attributes || []);
}, [attributes]);
const canCreate = permissions?.create !== 'n';
diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx
index 4bbdd3b..3a8d24c 100644
--- a/src/pages/views/trustee/TrusteeDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx
@@ -23,6 +23,7 @@ import api from '../../../api';
import styles from '../../admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
export const TrusteeDocumentsView: React.FC = () => {
const { t } = useLanguage();
@@ -70,7 +71,6 @@ export const TrusteeDocumentsView: React.FC = () => {
const allCols = (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,
@@ -88,7 +88,7 @@ export const TrusteeDocumentsView: React.FC = () => {
for (const col of allCols) {
if (byKey.has(col.key)) ordered.push(col);
}
- return ordered;
+ return resolveColumnTypes(ordered, attributes || []);
}, [attributes]);
// Check permissions
diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
index 749fc41..83b45c8 100644
--- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
@@ -14,6 +14,7 @@ import { FaSync } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
export const TrusteePositionDocumentsView: React.FC = () => {
const { t } = useLanguage();
@@ -56,12 +57,11 @@ export const TrusteePositionDocumentsView: React.FC = () => {
// Exclude system fields from table columns
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
- return attributes
+ const raw = attributes
.filter((attr: any) => !excludedFields.includes(attr.name))
.map((attr: any) => ({
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,
@@ -70,6 +70,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
maxWidth: attr.maxWidth || 400,
displayField: attr.displayField,
}));
+ return resolveColumnTypes(raw, attributes);
}, [attributes]);
// Check permissions (general level)
diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx
index 66122c2..bb5875a 100644
--- a/src/pages/views/trustee/TrusteePositionsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionsView.tsx
@@ -26,6 +26,7 @@ import { formatAmount, formatPercent } from '../../../utils/formatAmount';
import styles from '../../admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
+import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
export const TrusteePositionsView: React.FC = () => {
const { t } = useLanguage();
@@ -285,7 +286,6 @@ export const TrusteePositionsView: React.FC = () => {
const col: ColumnConfig = {
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,
@@ -320,7 +320,7 @@ export const TrusteePositionsView: React.FC = () => {
const col = byKey.get(key);
if (col) ordered.push(col);
}
- return ordered;
+ return resolveColumnTypes(ordered, attributes || []);
}, [attributes, belegeColumn, syncStatusColumn]);
// Check permissions
diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
index 1b6f5bf..75c00a1 100644
--- a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
+++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
@@ -25,6 +25,7 @@ import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGene
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
import adminStyles from '../../../admin/Admin.module.css';
+import { resolveColumnTypes } from '../../../../utils/columnTypeResolver';
export interface TrusteeDataTabProps {
/** Result of the entity hook factory call (see `useTrustee.ts`). */
@@ -117,12 +118,11 @@ export const TrusteeDataTab: React.FC = ({
const columns = useMemo(() => {
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
- return (attributes || [])
+ const raw = (attributes || [])
.filter((attr: any) => !hidden.has(attr.name))
.map((attr: any) => ({
key: attr.name,
label: attr.label || attr.name,
- type: (attr.type as any) || 'text',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
@@ -133,6 +133,7 @@ export const TrusteeDataTab: React.FC = ({
frontendFormat: attr.frontendFormat,
frontendFormatLabels: attr.frontendFormatLabels,
}));
+ return resolveColumnTypes(raw, attributes || []);
}, [attributes, hiddenColumns]);
const formAttributes = useMemo(() => {
diff --git a/src/utils/attributeTypeMapper.ts b/src/utils/attributeTypeMapper.ts
index f9e3517..1d18dcc 100644
--- a/src/utils/attributeTypeMapper.ts
+++ b/src/utils/attributeTypeMapper.ts
@@ -24,7 +24,9 @@ export type AttributeType =
| 'string'
| 'enum'
| 'slug'
- | 'readonly';
+ | 'readonly'
+ | 'object'
+ | 'json';
export type InputComponentType =
| 'text'
@@ -82,6 +84,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
return 'multiselect';
case 'integer':
+ case 'int':
case 'number':
case 'float':
return 'number';
@@ -113,6 +116,10 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
case 'readonly':
return 'text'; // Default to text for readonly, but should be rendered as readonly
+
+ case 'object':
+ case 'json':
+ return 'textarea';
default:
// Default fallback to text input
@@ -124,7 +131,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
* Determines if an attribute type should render as a textarea
*/
export function isTextareaType(attributeType: AttributeType): boolean {
- return attributeType === 'textarea';
+ return attributeType === 'textarea' || attributeType === 'object' || attributeType === 'json';
}
/**
@@ -166,7 +173,12 @@ export function isFileType(attributeType: AttributeType): boolean {
* Determines if an attribute type should render as a number input
*/
export function isNumberType(attributeType: AttributeType): boolean {
- return attributeType === 'integer' || attributeType === 'number' || attributeType === 'float';
+ return (
+ attributeType === 'integer'
+ || attributeType === 'int'
+ || attributeType === 'number'
+ || attributeType === 'float'
+ );
}
/**
@@ -176,6 +188,25 @@ export function isDateTimeType(attributeType: AttributeType): boolean {
return attributeType === 'timestamp' || attributeType === 'date' || attributeType === 'time';
}
+/**
+ * Subset of AttributeType suitable for user-facing form fields (workflow start forms,
+ * input forms, field builders). Components render labels via t(FORM_FIELD_TYPE_LABELS[ft]).
+ */
+export const FORM_FIELD_TYPES: AttributeType[] = [
+ 'text', 'textarea', 'number', 'email', 'date', 'boolean', 'select', 'checkbox',
+];
+
+export const FORM_FIELD_TYPE_LABELS: Record = {
+ text: 'Text',
+ textarea: 'Mehrzeilig',
+ number: 'Zahl',
+ email: 'E-Mail',
+ date: 'Datum',
+ boolean: 'Ja/Nein',
+ select: 'Auswahl',
+ checkbox: 'Kontrollkästchen',
+};
+
/**
* Gets the default value for an attribute type
*/
diff --git a/src/utils/columnTypeResolver.ts b/src/utils/columnTypeResolver.ts
new file mode 100644
index 0000000..7ce5e22
--- /dev/null
+++ b/src/utils/columnTypeResolver.ts
@@ -0,0 +1,57 @@
+/**
+ * Resolves column types from backend attribute definitions.
+ *
+ * Pages define column configs with UI metadata (label, width, formatter, etc.)
+ * but omit the `type` field. This utility merges the backend-provided attribute
+ * type into each column, ensuring a single source of truth for column types.
+ */
+
+import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
+import type { AttributeType } from './attributeTypeMapper';
+
+export interface AttributeLike {
+ name: string;
+ type?: string;
+ displayField?: string;
+ frontendFormat?: string;
+ frontendFormatLabels?: string[];
+}
+
+/**
+ * Merge backend attribute types into page-defined column configs.
+ *
+ * For each column, the `type` is resolved from the matching backend attribute
+ * (by `key === attr.name`). Page-level overrides for `type` are preserved only
+ * when no backend attribute is available (graceful degradation during loading).
+ */
+export function resolveColumnTypes(
+ columns: ColumnConfig[],
+ attributes: AttributeLike[],
+): ColumnConfig[] {
+ if (!attributes || attributes.length === 0) return columns;
+
+ const attrMap = new Map();
+ for (const attr of attributes) {
+ attrMap.set(attr.name, attr);
+ }
+
+ return columns.map((col) => {
+ const attr = attrMap.get(col.key);
+ if (!attr) return col;
+
+ const merged: ColumnConfig = { ...col };
+ if (attr.type) {
+ merged.type = attr.type as AttributeType;
+ }
+ if (attr.displayField && !col.displayField) {
+ merged.displayField = attr.displayField;
+ }
+ if (attr.frontendFormat && !col.frontendFormat) {
+ merged.frontendFormat = attr.frontendFormat;
+ }
+ if (attr.frontendFormatLabels && !col.frontendFormatLabels) {
+ merged.frontendFormatLabels = attr.frontendFormatLabels;
+ }
+ return merged;
+ });
+}
| | | | |