datamodel sctirc fk logic in one place

This commit is contained in:
ValueOn AG 2026-04-26 18:11:52 +02:00
parent d8ff3a84d9
commit 8679cdffcb
41 changed files with 1220 additions and 930 deletions

View file

@ -1,4 +1,7 @@
import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper';
export type { AttributeType };
// ============================================================================
// TYPES & INTERFACES
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface AttributeDefinition {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
type: AttributeType;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;

View file

@ -29,7 +29,7 @@ export interface BillingTransaction {
aicoreProvider?: string;
aicoreModel?: string;
createdByUserId?: string;
createdAt?: string;
sysCreatedAt?: string;
mandateId?: string;
mandateName?: string;
userId?: string;

View file

@ -2,45 +2,17 @@
* Form node config - draggable fields, types, required toggle
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
}) => {
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
useEffect(() => {
if (!instanceId || !request) {
setConnections([]);
return;
}
let cancelled = false;
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then((rows) => {
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
})
.catch(() => {
if (!cancelled) setConnections([]);
})
.finally(() => {
if (!cancelled) setConnectionsLoading(false);
});
return () => {
cancelled = true;
};
}, [instanceId, request]);
const moveField = (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
@ -108,33 +80,17 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
const fieldType = e.target.value;
next[i] = {
...next[i],
type: fieldType,
...(fieldType === 'clickup_tasks'
? { clickupStatusOptions: undefined }
: fieldType === 'clickup_status'
? { clickupConnectionId: undefined, clickupListId: undefined }
: {
clickupConnectionId: undefined,
clickupListId: undefined,
clickupStatusOptions: undefined,
}),
};
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Kontrollkästchen')}</option>
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
{FORM_FIELD_TYPES.map(ft => (
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
))}
</select>
<label className={styles.formFieldRequiredLabel}>
<input
@ -157,72 +113,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<FaTimes />
</button>
</div>
{f.type === 'clickup_status' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
<p style={{ margin: '0 0 6px' }}>
{t(
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
{ count: String(f.clickupStatusOptions.length) }
)}
</p>
) : (
<p style={{ margin: '0 0 6px' }}>
{t(
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
)}
</p>
)}
</div>
) : null}
{f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('ClickUp-Verbindung')}
</label>
<select
value={f.clickupConnectionId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupConnectionId: e.target.value };
updateParam('fields', next);
}}
disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
</label>
<input
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
value={f.clickupListId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupListId: e.target.value };
updateParam('fields', next);
}}
style={{ width: '100%' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
</p>
</div>
) : null}
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
}
>
+ {t('Feld')}

View file

@ -6,6 +6,7 @@
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
export interface FieldRendererProps {
param: NodeTypeParameter;
@ -547,12 +548,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="checkbox">{t('Kontrollkästchen')}</option>
<option value="select">{t('Auswahl')}</option>
<option value="textarea">{t('Mehrzeilig')}</option>
{FORM_FIELD_TYPES.map(ft => (
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
))}
<option value="group">{t('Gruppe')}</option>
</select>
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
@ -585,8 +583,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
}}
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
{FORM_FIELD_TYPES.map(ft => (
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
))}
</select>
<button
type="button"

View file

@ -358,8 +358,6 @@ function getFormFieldType(
if (rawFieldType === 'email') return 'email';
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
if (rawFieldType === 'clickup_tasks') return 'string';
if (rawFieldType === 'clickup_status') return 'string';
return 'string';
}

View file

@ -1,294 +0,0 @@
/**
* Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { FormField } from './types';
import { createRef } from './dataRef';
export type ClickUpFieldLike = Record<string, unknown>;
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
const rev: Record<string, string[]> = {};
for (const c of connections) {
if (!rev[c.targetId]) rev[c.targetId] = [];
rev[c.targetId].push(c.sourceId);
}
return rev;
}
/** Nearest form node upstream (toward triggers) of the ClickUp node. */
export function findClosestUpstreamFormNode(
targetNodeId: string,
nodes: CanvasNode[],
connections: CanvasConnection[]
): CanvasNode | null {
const nodeById = new Map(nodes.map((n) => [n.id, n]));
const rev = buildReverseAdjacency(connections);
const queue: string[] = [...(rev[targetNodeId] ?? [])];
const visited = new Set<string>();
while (queue.length > 0) {
const nid = queue.shift()!;
if (visited.has(nid)) continue;
visited.add(nid);
const n = nodeById.get(nid);
if (!n) continue;
if (n.type === 'input.form' || n.type === 'trigger.form') return n;
for (const p of rev[nid] ?? []) {
if (!visited.has(p)) queue.push(p);
}
}
return null;
}
export function normalizeClickUpFieldType(raw: unknown): string {
return String(raw ?? 'short_text')
.trim()
.toLowerCase()
.replace(/-/g, '_')
.replace(/\s+/g, '_');
}
function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null {
const tc = (field.type_config ?? {}) as Record<string, unknown>;
const asId = (v: unknown): string | null => {
if (typeof v === 'string' && v.trim()) return v.trim();
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
return null;
};
const keys = [
'linked_list_id',
'list_id',
'related_list_id',
'relationship_list_id',
'resource_id',
];
for (const k of keys) {
const raw = tc[k];
const id = asId(raw);
if (id) return id;
if (raw && typeof raw === 'object' && raw !== null) {
const nested = asId((raw as Record<string, unknown>).id);
if (nested) return nested;
}
}
const rel = tc.relationship;
if (rel && typeof rel === 'object' && rel !== null) {
const r = rel as Record<string, unknown>;
const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id);
if (fromRel) return fromRel;
}
return null;
}
function fieldUnsupported(ft: string): boolean {
return ['tasks', 'user', 'users'].includes(ft);
}
function mapCuToInputFormField(
field: ClickUpFieldLike,
connectionId: string,
parentListId: string
): FormField | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
const lid = linkedListIdFromRelationshipField(field) ?? parentListId;
return {
name,
label,
type: 'clickup_tasks',
required: false,
clickupConnectionId: connectionId,
clickupListId: lid,
};
}
if (
ft === 'drop_down' ||
ft === 'dropdown' ||
ft === 'text' ||
ft === 'long_text' ||
ft === 'short_text' ||
ft === 'email' ||
ft === 'phone' ||
ft === 'url'
) {
return { name, label, type: 'string', required: false };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number', required: false };
}
if (ft === 'date') {
return { name, label, type: 'date', required: false };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean', required: false };
}
return { name, label, type: 'string', required: false };
}
/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */
export type TriggerFormFieldRow = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
return { name, label, type: 'text' };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number' };
}
if (ft === 'date') {
return { name, label, type: 'date' };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean' };
}
if (ft === 'email') {
return { name, label, type: 'email' };
}
return { name, label, type: 'text' };
}
export const PAYLOAD_TITLE = 'title';
export const PAYLOAD_DESCRIPTION = 'description';
export const PAYLOAD_STATUS = 'clickup_status';
export const PAYLOAD_PRIORITY = 'clickup_priority';
export const PAYLOAD_DUE = 'clickup_due_date';
export const PAYLOAD_TIME_H = 'clickup_time_estimate_h';
/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */
export function statusOptionsFromListStatuses(
listStatuses: Array<{ status: string; orderindex: number }>
): Array<{ value: string; label: string }> {
return [...listStatuses]
.sort((a, b) => a.orderindex - b.orderindex)
.map((s) => ({ value: s.status, label: s.status }));
}
export interface SyncFromListResult {
inputFormFields: FormField[];
triggerFormFields: TriggerFormFieldRow[];
clickupPatch: Record<string, unknown>;
}
/**
* Build form field rows + ClickUp createTask parameter patch (refs payload.*).
*/
export function buildSyncFromClickUpList(args: {
formNodeId: string;
listFields: ClickUpFieldLike[];
/** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */
listStatuses: Array<{ status: string; orderindex: number }>;
connectionId: string;
teamId: string;
listId: string;
}): SyncFromListResult {
const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args;
const ref = (key: string) => createRef(formNodeId, ['payload', key]);
const statusOpts = statusOptionsFromListStatuses(listStatuses);
const standardInput: FormField[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false },
...(statusOpts.length > 0
? [
{
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
required: false,
clickupStatusOptions: statusOpts,
} as FormField,
]
: []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number', required: false },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
];
const statusTriggerRow: TriggerFormFieldRow | null =
statusOpts.length > 0
? {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
}
: null;
const standardTrigger: TriggerFormFieldRow[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
];
if (statusOpts.length > 0) {
standardTrigger.splice(2, 0, {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
});
}
const customInput: FormField[] = [];
const customTrigger: TriggerFormFieldRow[] = [];
const customRefs: Record<string, unknown> = {};
for (const f of listFields) {
if (!f || typeof f !== 'object') continue;
const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId);
const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId);
if (inf) customInput.push(inf);
if (tr) customTrigger.push(tr);
const fid = String((f as ClickUpFieldLike).id ?? '');
const payloadKey = inf?.name;
if (fid && payloadKey) {
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
}
}
const inputFormFields = [...standardInput, ...customInput];
const triggerFormFields = [...standardTrigger, ...customTrigger];
const clickupPatch: Record<string, unknown> = {
connectionId,
teamId,
listId,
path: `/team/${teamId}/list/${listId}`,
name: ref(PAYLOAD_TITLE),
description: ref(PAYLOAD_DESCRIPTION),
taskPriority: ref(PAYLOAD_PRIORITY),
taskDueDateMs: ref(PAYLOAD_DUE),
taskTimeEstimateHours: ref(PAYLOAD_TIME_H),
};
if (statusOpts.length > 0) {
clickupPatch.taskStatus = ref(PAYLOAD_STATUS);
}
if (Object.keys(customRefs).length) {
clickupPatch.customFieldValues = customRefs;
}
return { inputFormFields, triggerFormFields, clickupPatch };
}

View file

@ -3,17 +3,15 @@
*/
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import type { AttributeType } from '../../../../utils/attributeTypeMapper';
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
/** input.form / trigger.form field row. */
export type FormField = {
name?: string;
type?: string;
type?: AttributeType;
label?: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
clickupStatusOptions?: Array<{ value: string; label: string }>;
options?: Array<{ value: string; label: string }>;
};
export interface NodeConfigRendererProps {

View file

@ -4,40 +4,23 @@
import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
type FormField = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
const fieldType = String(o.type ?? 'text');
const rawType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return {
name,
label,
type: 'clickup_status',
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
};
}
return { name, label, type };
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField;
}
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
});
@ -64,7 +47,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Name (Payload-Key)')}
value={f.name}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
@ -74,7 +57,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Beschriftung')}
value={f.label}
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, label: e.target.value };
@ -83,24 +66,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/>
<select
className={styles.startsSelect}
value={f.type}
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
const fieldType = e.target.value as FormField['type'];
if (fieldType === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else {
next[idx] = { name: f.name, label: f.label, type: fieldType };
}
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
setFields(next);
}}
>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="email">{t('E-Mail')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Ja/Nein')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
{FORM_FIELD_TYPES.map(ft => (
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
))}
</select>
<button
type="button"

View file

@ -133,20 +133,26 @@
}
.table thead tr {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
.th {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary, #64748b);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--color-text-secondary, #475569);
white-space: nowrap;
overflow: visible;
user-select: none;
border-bottom: 2px solid var(--color-border, #e2e8f0);
border-bottom: 2px solid rgba(124, 109, 216, 0.35);
border-right: 1px solid #dde2ea;
}
.th:last-child {
border-right: none;
}
.th.actionsColumn {
@ -159,14 +165,13 @@
}
.th.sortable:hover {
background: #eef0f3;
background: #e4e8ef;
color: var(--color-text, #334155);
}
.headerContent {
display: flex;
align-items: center;
justify-content: left;
gap: 4px;
}
@ -230,8 +235,8 @@
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
max-width: 300px;
min-width: 200px;
max-width: 320px;
background: var(--color-bg);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
@ -303,6 +308,116 @@
font-style: italic;
}
/* Numeric column filter (operator + value / range) */
.filterNumericPanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterNumericRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterNumericLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterOperatorSelect,
.filterNumericInput {
width: 100%;
padding: 6px 8px;
font-size: 13px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterOperatorSelect:focus,
.filterNumericInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.filterNumericActions {
padding-top: 2px;
}
.filterApplyBtn {
width: 100%;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
border: none;
border-radius: 6px;
background: var(--color-secondary);
color: #fff;
}
.filterApplyBtn:hover {
opacity: 0.92;
}
/* PeriodPicker wrapper inside filter dropdown (date columns).
Rendered as sibling to .filterDropdownOptions so the PeriodPicker
popover (position: absolute, ~720 px) is not clipped by overflow. */
.filterDatePickerWrap {
padding: 6px 8px 8px;
overflow: visible;
}
.filterDatePickerWrap + .filterDropdownOptions {
display: none;
}
/* Date column filter (from / to) — legacy fallback */
.filterDatePanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterDateRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterDateLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterDateInput {
width: 100%;
padding: 6px 8px;
font-size: 12px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterDateInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.resizeHandle {
position: absolute;
top: 0;
@ -326,7 +441,8 @@
/* Table cells */
.td {
padding: 8px 12px;
border-top: 1px solid var(--color-border, #f1f5f9);
border-top: 1px solid var(--color-border, #e5e9ef);
border-right: 1px solid #eef0f4;
color: var(--color-text);
font-weight: 400;
font-size: 13px;
@ -338,21 +454,27 @@
overflow: visible;
}
.td:last-child {
border-right: none;
}
/* Rows */
.tr {
transition: background-color 0.12s ease;
transition: background-color 0.12s ease, box-shadow 0.12s ease;
}
.tr:hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(0, 0, 0, 0.015);
background: rgba(0, 0, 0, 0.025);
}
.tr:nth-child(even):hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {
@ -372,7 +494,7 @@
}
thead .selectColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .selectColumn {
@ -423,7 +545,7 @@ tbody .selectColumn {
}
thead .actionsColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .actionsColumn {
@ -708,7 +830,11 @@ tbody .actionsColumn {
height: auto;
}
.th,
.th {
padding: 6px 8px;
font-size: 10px;
}
.td {
padding: 6px 8px;
font-size: 12px;
@ -758,29 +884,40 @@ tbody .actionsColumn {
/* Dark theme */
@media (prefers-color-scheme: dark) {
.table thead tr {
background: #2a2d31;
background: #2d3038;
}
.th {
background: #2a2d31;
border-bottom-color: rgba(255, 255, 255, 0.12);
background: #2d3038;
border-bottom: 2px solid rgba(124, 109, 216, 0.3);
border-right-color: rgba(255, 255, 255, 0.08);
}
.td {
border-right-color: rgba(255, 255, 255, 0.06);
}
thead .selectColumn,
thead .actionsColumn {
background: #2a2d31;
background: #2d3038;
}
.th.sortable:hover {
background: #32363b;
background: #363a42;
}
.tr:hover {
background: rgba(255, 255, 255, 0.04);
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
background: rgba(255, 255, 255, 0.03);
}
.tr:nth-child(even):hover {
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {

View file

@ -31,7 +31,8 @@
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
* Cross-filtering is supported: changing a filter invalidates the cache,
* so re-opening another column's dropdown re-fetches with current filters.
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker;
* numeric columns render as operator + value (eq, gt, gte, lt, lte, between).
*
* BACKEND RESPONSE FORMAT (for refetch):
* { items: T[], pagination: PaginationMetadata | null }
@ -53,7 +54,7 @@
*
* See useOrgUsers / AdminUsersPage for a full reference implementation.
*/
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import type { IconType } from 'react-icons';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorTable.module.css';
@ -70,11 +71,14 @@ import { FormGeneratorControls } from '../FormGeneratorControls';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import {
isDateTimeType,
isCheckboxType
isCheckboxType,
isNumberType,
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { FaFilter } from 'react-icons/fa';
import api from '../../../api';
import { PeriodPicker } from '../../PeriodPicker';
import type { PeriodValue } from '../../PeriodPicker';
/** A filter value can be a plain string, null (for empty/missing), or a
* {value, label} object returned by FK-aware filter-values endpoints. */
@ -348,6 +352,188 @@ function FilterValuesList({
);
}
/** PowerOn audit / booking timestamps stored as float (unix) but ``frontend_type`` is ``timestamp``. */
function _auditTimestampColumnKey(key: string): boolean {
const k = key.toLowerCase();
const exact = new Set([
'syscreatedat', 'sysmodifiedat', 'createdat', 'updatedat', 'modifiedat',
'deletedat', 'syncedat', 'bookingdate', 'chartcachedat',
]);
if (exact.has(k)) return true;
if (k.endsWith('timestamp')) return true;
return false;
}
type _NumericUiOp = 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between';
function _parseNumericFilter(activeFilter: any): { op: _NumericUiOp; single: string; from: string; to: string } {
if (activeFilter === undefined || activeFilter === null || activeFilter === '') {
return { op: 'eq', single: '', from: '', to: '' };
}
if (typeof activeFilter === 'string' || typeof activeFilter === 'number') {
return { op: 'eq', single: String(activeFilter), from: '', to: '' };
}
if (typeof activeFilter === 'object' && activeFilter !== null && 'operator' in activeFilter) {
const opRaw = activeFilter.operator;
const val = activeFilter.value;
if (opRaw === 'between' && val && typeof val === 'object' && !Array.isArray(val)) {
return {
op: 'between',
single: '',
from: val.from != null ? String(val.from) : '',
to: val.to != null ? String(val.to) : '',
};
}
if (opRaw === 'equals' || opRaw === 'eq') {
return { op: 'eq', single: val != null && val !== '' ? String(val) : '', from: '', to: '' };
}
if (opRaw === 'gt' || opRaw === 'gte' || opRaw === 'lt' || opRaw === 'lte') {
return { op: opRaw, single: val != null && val !== '' ? String(val) : '', from: '', to: '' };
}
}
return { op: 'eq', single: '', from: '', to: '' };
}
/** Column filter UI for numeric types (operator + value / range). */
function NumericFilterPanel({
columnKey,
activeFilter,
onFilter,
onClear,
t,
}: {
columnKey: string;
activeFilter: any;
onFilter: (payload: { operator: string; value: any }, keepOpen?: boolean) => void;
onClear: () => void;
t: (key: string, params?: Record<string, string | number | boolean>) => string;
}) {
const parsed = _parseNumericFilter(activeFilter);
const [op, setOp] = useState<_NumericUiOp>(parsed.op);
const [single, setSingle] = useState(parsed.single);
const [from, setFrom] = useState(parsed.from);
const [to, setTo] = useState(parsed.to);
useEffect(() => {
const p = _parseNumericFilter(activeFilter);
setOp(p.op);
setSingle(p.single);
setFrom(p.from);
setTo(p.to);
}, [columnKey, activeFilter]);
const _apply = () => {
if (op === 'between') {
const f = from.trim();
const t0 = to.trim();
if (!f && !t0) {
onClear();
return;
}
onFilter({ operator: 'between', value: { from: f, to: t0 } }, true);
return;
}
const trimmed = single.trim().replace(/'/g, '').replace(/\s/g, '');
if (trimmed === '') {
onClear();
return;
}
const n = Number(trimmed);
if (Number.isNaN(n)) {
return;
}
const apiOp = op === 'eq' ? 'equals' : op;
onFilter({ operator: apiOp, value: n }, true);
};
const hasActive = activeFilter !== undefined && activeFilter !== null && activeFilter !== '';
return (
<div className={styles.filterNumericPanel}>
<div
className={`${styles.filterOption} ${!hasActive ? styles.filterOptionSelected : ''}`}
onClick={onClear}
>
({t('Alle')})
</div>
<div className={styles.filterNumericRow}>
<label className={styles.filterNumericLabel} htmlFor={`fg-num-op-${columnKey}`}>
{t('Vergleich')}
</label>
<select
id={`fg-num-op-${columnKey}`}
className={styles.filterOperatorSelect}
value={op}
onChange={(e) => setOp(e.target.value as _NumericUiOp)}
onClick={(e) => e.stopPropagation()}
>
<option value="eq">=</option>
<option value="gt">&gt;</option>
<option value="gte"></option>
<option value="lt">&lt;</option>
<option value="lte"></option>
<option value="between">{t('Zwischen')}</option>
</select>
</div>
{op === 'between' ? (
<>
<div className={styles.filterNumericRow}>
<label className={styles.filterNumericLabel} htmlFor={`fg-num-from-${columnKey}`}>
{t('Von')}
</label>
<input
id={`fg-num-from-${columnKey}`}
type="number"
className={styles.filterNumericInput}
value={from}
onChange={(e) => setFrom(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className={styles.filterNumericRow}>
<label className={styles.filterNumericLabel} htmlFor={`fg-num-to-${columnKey}`}>
{t('Bis')}
</label>
<input
id={`fg-num-to-${columnKey}`}
type="number"
className={styles.filterNumericInput}
value={to}
onChange={(e) => setTo(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</>
) : (
<div className={styles.filterNumericRow}>
<label className={styles.filterNumericLabel} htmlFor={`fg-num-val-${columnKey}`}>
{t('Wert')}
</label>
<input
id={`fg-num-val-${columnKey}`}
type="number"
className={styles.filterNumericInput}
value={single}
onChange={(e) => setSingle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
_apply();
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<div className={styles.filterNumericActions}>
<button type="button" className={styles.filterApplyBtn} onClick={(e) => { e.stopPropagation(); _apply(); }}>
{t('Anwenden')}
</button>
</div>
</div>
);
}
export function FormGeneratorTable<T extends Record<string, any>>({
data,
columns: providedColumns,
@ -545,6 +731,41 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
const filterDropdownRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!openFilterColumn) return;
const dd = filterDropdownRef.current;
if (!dd) return;
const positionDropdown = () => {
const th = dd.closest('th');
if (!th) return;
const r = th.getBoundingClientRect();
const margin = 8;
const maxW = 320;
const w = Math.min(Math.max(dd.offsetWidth || maxW, 200), maxW, window.innerWidth - 2 * margin);
let left = r.left;
if (left + w > window.innerWidth - margin) {
left = window.innerWidth - margin - w;
}
if (left < margin) left = margin;
const approxH = dd.offsetHeight || 280;
let top = r.bottom + 4;
if (top + approxH > window.innerHeight - margin) {
top = Math.max(margin, r.top - 4 - approxH);
}
dd.style.position = 'fixed';
dd.style.left = `${left}px`;
dd.style.top = `${top}px`;
dd.style.right = 'auto';
dd.style.bottom = 'auto';
dd.style.width = `${w}px`;
dd.style.maxWidth = `${maxW}px`;
dd.style.zIndex = '2000';
};
positionDropdown();
const id = requestAnimationFrame(() => positionDropdown());
return () => cancelAnimationFrame(id);
}, [openFilterColumn]);
// Grouping: Track expanded groups
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
const [groupsInitialized, setGroupsInitialized] = useState(false);
@ -945,6 +1166,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Skip if column has static filterOptions (enum) those are used directly
if (column?.filterOptions && column.filterOptions.length > 0) return;
// Boolean / date / number columns use dedicated filter UIs — no distinct-value fetch
const colT = column?.type as AttributeType | undefined;
const auditTs = column?.key ? _auditTimestampColumnKey(column.key) : false;
const treatAsDate = !!colT && (
isDateTimeType(colT)
|| (isNumberType(colT) && auditTs)
);
if (column?.type && (
isCheckboxType(colT as AttributeType)
|| treatAsDate
|| (isNumberType(colT as AttributeType) && !auditTs)
)) {
return;
}
// displayField + local full dataset: filter values are derived from `data` (see getUniqueValuesForColumn)
if (column?.displayField && !supportsBackendPagination) return;
@ -1619,6 +1855,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return false;
};
const _columnAlignStyle = (column: ColumnConfig): React.CSSProperties => {
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
if (formatAlign === 'R') return { textAlign: 'right' };
if (formatAlign === 'M') return { textAlign: 'center' };
if (formatAlign === 'L') return { textAlign: 'left' };
return isNumeric ? { textAlign: 'right' } : {};
};
// Format cell value
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
// Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs)
@ -1980,7 +2225,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
</th>
)}
{detectedColumns.map(column => (
{detectedColumns.map(column => {
const colAlign = _columnAlignStyle(column);
const headerJustify = colAlign.textAlign === 'right' ? 'flex-end'
: colAlign.textAlign === 'center' ? 'center' : 'flex-start';
return (
<th
key={column.key}
className={`${styles.th} ${sortable && column.sortable ? styles.sortable : ''}`}
@ -1988,10 +2237,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
width: columnWidths[column.key] || column.width || 150,
minWidth: columnWidths[column.key] || column.width || 150,
maxWidth: columnWidths[column.key] || column.width || 150,
position: 'relative'
position: 'relative',
...colAlign,
}}
>
<div className={styles.headerContent}>
<div className={styles.headerContent} style={{ justifyContent: headerJustify }}>
{/* Filter icon */}
{filterable && column.filterable !== false && (
<button
@ -2034,6 +2284,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<span
className={styles.columnLabel}
onClick={() => column.sortable && handleSort(column.key)}
title={column.label}
>
{column.label}
</span>
@ -2058,11 +2309,77 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</button>
)}
</div>
{(() => {
const colType = column.type || 'text';
const auditTs = _auditTimestampColumnKey(column.key);
const isDateCol = isDateTimeType(colType as AttributeType)
|| (isNumberType(colType as AttributeType) && auditTs);
if (isDateCol) {
const filterVal = filters[column.key];
const periodVal: PeriodValue | null = (() => {
if (!filterVal || typeof filterVal !== 'object') return null;
const v = (filterVal as any).value;
if (!v || typeof v !== 'object') return null;
const { from, to } = v as { from?: string; to?: string };
if (!from && !to) return null;
const storedPresetKind = (filterVal as any).presetKind;
let preset: PeriodValue['preset'];
if (storedPresetKind === 'lastN' || storedPresetKind === 'nextN') {
const amount = (filterVal as any).presetAmount ?? 7;
const unit = (filterVal as any).presetUnit ?? 'day';
preset = { kind: storedPresetKind, amount, unit };
} else if (storedPresetKind) {
preset = { kind: storedPresetKind } as PeriodValue['preset'];
} else {
preset = { kind: 'custom' as const };
}
return { preset, fromDate: from || '', toDate: to || '' };
})();
return (
<div className={styles.filterDatePickerWrap}>
<PeriodPicker
value={periodVal}
onChange={(next: PeriodValue) => {
if (next.preset.kind === 'allTime') {
clearFilter(column.key);
} else {
const filterPayload: any = {
operator: 'between',
value: { from: next.fromDate, to: next.toDate },
presetKind: next.preset.kind,
};
if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') {
filterPayload.presetAmount = (next.preset as any).amount;
filterPayload.presetUnit = (next.preset as any).unit;
}
handleFilter(column.key, filterPayload, true);
}
}}
direction="past"
enabledPresets={[
'allTime', 'ytd', 'lastYear', 'last12Months',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'lastN', 'custom',
]}
placeholder={t('Zeitraum wählen')}
/>
</div>
);
}
return null;
})()}
<div className={styles.filterDropdownOptions}>
{(() => {
const colType = column.type || 'text';
const isBool = isCheckboxType(colType as AttributeType);
const isDate = isDateTimeType(colType as AttributeType);
const auditTs = _auditTimestampColumnKey(column.key);
const isDateCol = isDateTimeType(colType as AttributeType)
|| (isNumberType(colType as AttributeType) && auditTs);
const isNum = isNumberType(colType as AttributeType) && !auditTs;
if (isDateCol) return null;
if (isBool) {
const currentVal = filters[column.key];
@ -2090,51 +2407,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
);
}
if (isDate) {
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
if (isNum) {
return (
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
onClick={() => clearFilter(column.key)}
>
({t('Alle')})
</div>
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
{t('Von')}
</label>
<input
type="date"
value={rangeVal.from || ''}
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
onChange={(e) => {
const from = e.target.value;
const to = rangeVal.to || '';
if (!from && !to) {
clearFilter(column.key);
} else {
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
}
}}
/>
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
{t('Bis')}
</label>
<input
type="date"
value={rangeVal.to || ''}
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
onChange={(e) => {
const to = e.target.value;
const from = rangeVal.from || '';
if (!from && !to) {
clearFilter(column.key);
} else {
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
}
}}
/>
</div>
<NumericFilterPanel
columnKey={column.key}
activeFilter={filters[column.key]}
onFilter={(payload, keepOpen = true) => handleFilter(column.key, payload, keepOpen)}
onClear={() => clearFilter(column.key)}
t={t}
/>
);
}
@ -2184,7 +2465,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
/>
)}
</th>
))}
);
})}
</tr>
</thead>
<tbody>
@ -2362,15 +2644,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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 (
<td key={column.key} className={combinedClassName}
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>
@ -2486,17 +2760,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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 (
<td key={column.key} className={combinedClassName}
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>

View file

@ -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<PeriodPickerPopoverProps> = (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 (
<div ref={popRef} className={styles.popover}>
<div className={styles.body}>

View file

@ -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;

View file

@ -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<Mandate>): Promise<Mandate | null> => {

View file

@ -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<WorkflowRunMetrics | null>(null);
@ -431,6 +435,13 @@ const _DashboardTab: React.FC = () => {
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
const lastPaginationParamsRef = useRef<any>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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<any>(null);
const lastPaginationParamsRef = useRef<any>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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),

View file

@ -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<AttributeDefinition[]>([]);
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
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<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(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(

View file

@ -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<Mandate[]>([]);
@ -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(() => {

View file

@ -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<AttributeDefinition[]>([]);
// 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 => ({

View file

@ -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<AttributeDefinition[]>([]);
// State
const [features, setFeatures] = useState<Feature[]>([]);
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
useEffect(() => {
fetchAttributes(request, 'Role')
.then(setRoleTableAttributes)
.catch(() => setRoleTableAttributes([]));
}, [request]);
// Load features on mount
useEffect(() => {
const loadFeatures = async () => {
@ -130,29 +143,25 @@ export const AdminFeatureRolesPage: React.FC = () => {
return String(value);
};
// Table columns
const columns = useMemo(() => [
const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'roleLabel',
label: t('Rollen-Label'),
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 180
width: 180,
},
{
key: 'description',
label: t('Beschreibung'),
type: 'string' as const,
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,
width: 120,
@ -160,10 +169,15 @@ export const AdminFeatureRolesPage: React.FC = () => {
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
<FaCube style={{ marginRight: 4 }} /> {value}
</span>
)
),
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, roleTableAttributes),
[_rawColumns, roleTableAttributes],
);
// Form attributes for create
const createFields: AttributeDefinition[] = useMemo(() => {
const fields: AttributeDefinition[] = [

View file

@ -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,12 +86,10 @@ export const AdminInvitationsPage: React.FC = () => {
});
};
// Table columns
const columns = useMemo(() => [
const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'targetUsername',
label: t('Benutzername'),
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
@ -98,11 +98,10 @@ export const AdminInvitationsPage: React.FC = () => {
{
key: 'email',
label: t('E-Mail'),
type: 'string' as const,
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 && '✓'}
</span>
);
}
},
},
{
key: 'roleIds',
label: t('Rollen'),
type: 'string', // Array rendered as string
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,
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)'}
</span>
);
}
},
},
{
key: 'currentUses',
label: t('Verwendet'),
type: 'string' as const,
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',
key: 'sysCreatedAt',
label: t('Erstellt'),
type: 'number' as const,
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

View file

@ -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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
{t('wird aktualisiert…')}
</span>
);
}
if (r.status === 'generating') {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
{t('wird erzeugt…')}
</span>
);
}
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<AttributeDefinition[]>([]);
const [rows, setRows] = useState<LangRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -288,6 +256,12 @@ export const AdminLanguagesPage: React.FC = () => {
const busyRef = useRef(false);
const abortRef = useRef<AbortController | null>(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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
{t('wird aktualisiert…')}
</span>
);
}
if (r.status === 'generating') {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
{t('wird erzeugt…')}
</span>
);
}
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 = () => {
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={displayRows}
columns={_getColumns(t)}
columns={columns}
loading={loading}
pagination={false}
selectable={false}

View file

@ -21,7 +21,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } 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';
@ -31,6 +34,7 @@ export const AdminMandateRolesPage: 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,30 +104,26 @@ export const AdminMandateRolesPage: React.FC = () => {
return String(desc);
};
// Table columns - scopeType is now a backend-computed field
const columns = useMemo(() => [
const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'roleLabel',
label: t('Bezeichnung'),
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 150
width: 150,
},
{
key: 'description',
label: t('Beschreibung'),
type: 'string' as const,
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,
width: 140,
@ -149,10 +147,15 @@ export const AdminMandateRolesPage: React.FC = () => {
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
</span>
);
}
},
},
], [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'];

View file

@ -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(() =>

View file

@ -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<User | null>(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

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -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<AttributeDefinition[]>([]);
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 (
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<FormGeneratorTable
data={subscriptions}
columns={_getColumns(t)}
columns={columns}
apiEndpoint="/api/subscription/admin/all"
loading={loading}
pagination={true}

View file

@ -13,6 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
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 { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
@ -252,6 +256,8 @@ function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'ba
export const BillingDataView: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [billingTxnAttributes, setBillingTxnAttributes] = useState<AttributeDefinition[]>([]);
const [activeTab, setActiveTab] = useState<TabType>('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<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(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<string, string> => {
@ -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

View file

@ -145,7 +145,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
<tbody>
{transactions.map((txn) => (
<tr key={txn.id}>
<td>{formatDate(txn.createdAt)}</td>
<td>{formatDate(txn.sysCreatedAt)}</td>
<td>{txn.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>

View file

@ -58,7 +58,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
return (
<tr>
<td>{formatDate(transaction.createdAt)}</td>
<td>{formatDate(transaction.sysCreatedAt)}</td>
<td>{transaction.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>

View file

@ -239,7 +239,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
<tbody>
{filteredTransactions.map((txn) => (
<tr key={txn.id}>
<td>{formatDate(txn.createdAt)}</td>
<td>{formatDate(txn.sysCreatedAt)}</td>
<td>{txn.mandateName || '-'}</td>
<td>{txn.userName || '-'}</td>
<td>

View file

@ -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<string | null>(null);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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 (
<div className={styles.adminPage}>

View file

@ -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<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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,

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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<TrusteeDataTabProps> = ({
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<TrusteeDataTabProps> = ({
frontendFormat: attr.frontendFormat,
frontendFormatLabels: attr.frontendFormatLabels,
}));
return resolveColumnTypes(raw, attributes || []);
}, [attributes, hiddenColumns]);
const formAttributes = useMemo(() => {

View file

@ -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';
@ -114,6 +117,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
return 'text';
@ -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<string, string> = {
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
*/

View file

@ -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<string, AttributeLike>();
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;
});
}