datamodel sctirc fk logic in one place
This commit is contained in:
parent
d8ff3a84d9
commit
8679cdffcb
41 changed files with 1220 additions and 930 deletions
|
|
@ -1,4 +1,7 @@
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||||
|
|
||||||
|
export type { AttributeType };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
|
|
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
export interface AttributeDefinition {
|
export interface AttributeDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
|
type: AttributeType;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export interface BillingTransaction {
|
||||||
aicoreProvider?: string;
|
aicoreProvider?: string;
|
||||||
aicoreModel?: string;
|
aicoreModel?: string;
|
||||||
createdByUserId?: string;
|
createdByUserId?: string;
|
||||||
createdAt?: string;
|
sysCreatedAt?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
mandateName?: string;
|
mandateName?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
|
||||||
|
|
@ -2,45 +2,17 @@
|
||||||
* Form node config - draggable fields, types, required toggle
|
* 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 { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
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 styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
updateParam,
|
|
||||||
instanceId,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const fields = (params.fields as FormField[]) ?? [];
|
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) => {
|
const moveField = (fromIndex: number, toIndex: number) => {
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
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>
|
||||||
<div className={styles.formFieldRowFooter}>
|
<div className={styles.formFieldRowFooter}>
|
||||||
<select
|
<select
|
||||||
value={f.type ?? 'string'}
|
value={f.type ?? 'text'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
const fieldType = e.target.value;
|
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
|
||||||
next[i] = {
|
|
||||||
...next[i],
|
|
||||||
type: fieldType,
|
|
||||||
...(fieldType === 'clickup_tasks'
|
|
||||||
? { clickupStatusOptions: undefined }
|
|
||||||
: fieldType === 'clickup_status'
|
|
||||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
|
||||||
: {
|
|
||||||
clickupConnectionId: undefined,
|
|
||||||
clickupListId: undefined,
|
|
||||||
clickupStatusOptions: undefined,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
updateParam('fields', next);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
style={{ width: 'auto', minWidth: 90 }}
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
>
|
>
|
||||||
<option value="string">{t('Text')}</option>
|
{FORM_FIELD_TYPES.map(ft => (
|
||||||
<option value="number">{t('Zahl')}</option>
|
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</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>
|
|
||||||
</select>
|
</select>
|
||||||
<label className={styles.formFieldRequiredLabel}>
|
<label className={styles.formFieldRequiredLabel}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -157,72 +113,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
+ {t('Feld')}
|
+ {t('Feld')}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||||
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
|
|
||||||
export interface FieldRendererProps {
|
export interface FieldRendererProps {
|
||||||
param: NodeTypeParameter;
|
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' }}>
|
<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' }} />
|
<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' }}>
|
<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>
|
{FORM_FIELD_TYPES.map(ft => (
|
||||||
<option value="number">{t('Zahl')}</option>
|
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</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>
|
|
||||||
<option value="group">{t('Gruppe')}</option>
|
<option value="group">{t('Gruppe')}</option>
|
||||||
</select>
|
</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' }} />
|
<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' }}
|
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
>
|
>
|
||||||
<option value="text">{t('Text')}</option>
|
{FORM_FIELD_TYPES.map(ft => (
|
||||||
<option value="number">{t('Zahl')}</option>
|
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -358,8 +358,6 @@ function getFormFieldType(
|
||||||
if (rawFieldType === 'email') return 'email';
|
if (rawFieldType === 'email') return 'email';
|
||||||
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||||||
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||||||
if (rawFieldType === 'clickup_tasks') return 'string';
|
|
||||||
if (rawFieldType === 'clickup_status') return 'string';
|
|
||||||
return 'string';
|
return 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (1–4)', 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 (1–4)', 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -3,17 +3,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
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 = {
|
export type FormField = {
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: string;
|
type?: AttributeType;
|
||||||
label?: string;
|
label?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
clickupConnectionId?: string;
|
options?: Array<{ value: string; label: string }>;
|
||||||
clickupListId?: string;
|
|
||||||
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
|
|
||||||
clickupStatusOptions?: Array<{ value: string; label: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface NodeConfigRendererProps {
|
export interface NodeConfigRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,23 @@
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../shared/types';
|
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 styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
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[] {
|
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
|
||||||
const raw = params.formFields;
|
const raw = params.formFields;
|
||||||
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
|
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
|
||||||
return raw.map((f, i) => {
|
return raw.map((f, i) => {
|
||||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||||
const o = f as Record<string, unknown>;
|
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 name = String(o.name ?? `field${i + 1}`);
|
||||||
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||||
const type = (
|
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
|
||||||
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
|
return { name, label, type } as FormField;
|
||||||
) 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 };
|
|
||||||
}
|
}
|
||||||
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
|
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
|
<input
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
placeholder={t('Name (Payload-Key)')}
|
placeholder={t('Name (Payload-Key)')}
|
||||||
value={f.name}
|
value={f.name ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[idx] = { ...f, name: e.target.value };
|
next[idx] = { ...f, name: e.target.value };
|
||||||
|
|
@ -74,7 +57,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
<input
|
<input
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
placeholder={t('Beschriftung')}
|
placeholder={t('Beschriftung')}
|
||||||
value={f.label}
|
value={f.label ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[idx] = { ...f, label: e.target.value };
|
next[idx] = { ...f, label: e.target.value };
|
||||||
|
|
@ -83,24 +66,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className={styles.startsSelect}
|
className={styles.startsSelect}
|
||||||
value={f.type}
|
value={f.type ?? 'text'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
const fieldType = e.target.value as FormField['type'];
|
next[idx] = { name: f.name, label: f.label, type: 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 };
|
|
||||||
}
|
|
||||||
setFields(next);
|
setFields(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="text">{t('Text')}</option>
|
{FORM_FIELD_TYPES.map(ft => (
|
||||||
<option value="number">{t('Zahl')}</option>
|
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</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>
|
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -133,20 +133,26 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.table thead tr {
|
.table thead tr {
|
||||||
background: var(--table-header-bg, #f8f9fa);
|
background: var(--table-header-bg, #edf0f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.th {
|
||||||
background: var(--table-header-bg, #f8f9fa);
|
background: var(--table-header-bg, #edf0f5);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
color: var(--color-text-secondary, #64748b);
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-text-secondary, #475569);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
user-select: none;
|
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 {
|
.th.actionsColumn {
|
||||||
|
|
@ -159,14 +165,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable:hover {
|
.th.sortable:hover {
|
||||||
background: #eef0f3;
|
background: #e4e8ef;
|
||||||
color: var(--color-text, #334155);
|
color: var(--color-text, #334155);
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerContent {
|
.headerContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: left;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,8 +235,8 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 200px;
|
||||||
max-width: 300px;
|
max-width: 320px;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border, #e2e8f0);
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
@ -303,6 +308,116 @@
|
||||||
font-style: italic;
|
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 {
|
.resizeHandle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -326,7 +441,8 @@
|
||||||
/* Table cells */
|
/* Table cells */
|
||||||
.td {
|
.td {
|
||||||
padding: 8px 12px;
|
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);
|
color: var(--color-text);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -338,21 +454,27 @@
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.td:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Rows */
|
/* Rows */
|
||||||
.tr {
|
.tr {
|
||||||
transition: background-color 0.12s ease;
|
transition: background-color 0.12s ease, box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:hover {
|
.tr:hover {
|
||||||
background: var(--color-gray-disabled, #f8fafc);
|
background: #f0f4ff;
|
||||||
|
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:nth-child(even) {
|
.tr:nth-child(even) {
|
||||||
background: rgba(0, 0, 0, 0.015);
|
background: rgba(0, 0, 0, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:nth-child(even):hover {
|
.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 {
|
.tr.selected {
|
||||||
|
|
@ -372,7 +494,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
thead .selectColumn {
|
thead .selectColumn {
|
||||||
background: var(--table-header-bg, #f8f9fa);
|
background: var(--table-header-bg, #edf0f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody .selectColumn {
|
tbody .selectColumn {
|
||||||
|
|
@ -423,7 +545,7 @@ tbody .selectColumn {
|
||||||
}
|
}
|
||||||
|
|
||||||
thead .actionsColumn {
|
thead .actionsColumn {
|
||||||
background: var(--table-header-bg, #f8f9fa);
|
background: var(--table-header-bg, #edf0f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody .actionsColumn {
|
tbody .actionsColumn {
|
||||||
|
|
@ -708,7 +830,11 @@ tbody .actionsColumn {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th,
|
.th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.td {
|
.td {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -758,29 +884,40 @@ tbody .actionsColumn {
|
||||||
/* Dark theme */
|
/* Dark theme */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.table thead tr {
|
.table thead tr {
|
||||||
background: #2a2d31;
|
background: #2d3038;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.th {
|
||||||
background: #2a2d31;
|
background: #2d3038;
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
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 .selectColumn,
|
||||||
thead .actionsColumn {
|
thead .actionsColumn {
|
||||||
background: #2a2d31;
|
background: #2d3038;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable:hover {
|
.th.sortable:hover {
|
||||||
background: #32363b;
|
background: #363a42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:hover {
|
.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) {
|
.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 {
|
.tr.selected {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
|
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
|
||||||
* Cross-filtering is supported: changing a filter invalidates the cache,
|
* Cross-filtering is supported: changing a filter invalidates the cache,
|
||||||
* so re-opening another column's dropdown re-fetches with current filters.
|
* 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):
|
* BACKEND RESPONSE FORMAT (for refetch):
|
||||||
* { items: T[], pagination: PaginationMetadata | null }
|
* { items: T[], pagination: PaginationMetadata | null }
|
||||||
|
|
@ -53,7 +54,7 @@
|
||||||
*
|
*
|
||||||
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
* 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 type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorTable.module.css';
|
import styles from './FormGeneratorTable.module.css';
|
||||||
|
|
@ -70,11 +71,14 @@ import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||||
import {
|
import {
|
||||||
isDateTimeType,
|
isDateTimeType,
|
||||||
isCheckboxType
|
isCheckboxType,
|
||||||
|
isNumberType,
|
||||||
} from '../../../utils/attributeTypeMapper';
|
} from '../../../utils/attributeTypeMapper';
|
||||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||||
import { FaFilter } from 'react-icons/fa';
|
import { FaFilter } from 'react-icons/fa';
|
||||||
import api from '../../../api';
|
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
|
/** A filter value can be a plain string, null (for empty/missing), or a
|
||||||
* {value, label} object returned by FK-aware filter-values endpoints. */
|
* {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">></option>
|
||||||
|
<option value="gte">≥</option>
|
||||||
|
<option value="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>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
data,
|
data,
|
||||||
columns: providedColumns,
|
columns: providedColumns,
|
||||||
|
|
@ -545,6 +731,41 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||||
const filterDropdownRef = useRef<HTMLDivElement>(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
|
// Grouping: Track expanded groups
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
|
||||||
const [groupsInitialized, setGroupsInitialized] = useState(false);
|
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
|
// Skip if column has static filterOptions (enum) – those are used directly
|
||||||
if (column?.filterOptions && column.filterOptions.length > 0) return;
|
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)
|
// displayField + local full dataset: filter values are derived from `data` (see getUniqueValuesForColumn)
|
||||||
if (column?.displayField && !supportsBackendPagination) return;
|
if (column?.displayField && !supportsBackendPagination) return;
|
||||||
|
|
||||||
|
|
@ -1619,6 +1855,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return false;
|
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
|
// Format cell value
|
||||||
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
||||||
// Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs)
|
// 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>
|
</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
|
<th
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className={`${styles.th} ${sortable && column.sortable ? styles.sortable : ''}`}
|
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,
|
width: columnWidths[column.key] || column.width || 150,
|
||||||
minWidth: columnWidths[column.key] || column.width || 150,
|
minWidth: columnWidths[column.key] || column.width || 150,
|
||||||
maxWidth: 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 */}
|
{/* Filter icon */}
|
||||||
{filterable && column.filterable !== false && (
|
{filterable && column.filterable !== false && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -2034,6 +2284,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
<span
|
<span
|
||||||
className={styles.columnLabel}
|
className={styles.columnLabel}
|
||||||
onClick={() => column.sortable && handleSort(column.key)}
|
onClick={() => column.sortable && handleSort(column.key)}
|
||||||
|
title={column.label}
|
||||||
>
|
>
|
||||||
{column.label}
|
{column.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2058,11 +2309,77 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<div className={styles.filterDropdownOptions}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const colType = column.type || 'text';
|
const colType = column.type || 'text';
|
||||||
const isBool = isCheckboxType(colType as AttributeType);
|
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) {
|
if (isBool) {
|
||||||
const currentVal = filters[column.key];
|
const currentVal = filters[column.key];
|
||||||
|
|
@ -2090,51 +2407,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDate) {
|
if (isNum) {
|
||||||
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
<NumericFilterPanel
|
||||||
<div
|
columnKey={column.key}
|
||||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
activeFilter={filters[column.key]}
|
||||||
onClick={() => clearFilter(column.key)}
|
onFilter={(payload, keepOpen = true) => handleFilter(column.key, payload, keepOpen)}
|
||||||
>
|
onClear={() => clearFilter(column.key)}
|
||||||
({t('Alle')})
|
t={t}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2184,7 +2465,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -2362,15 +2644,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const cellValue = row[column.key];
|
const cellValue = row[column.key];
|
||||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
const alignStyle = _columnAlignStyle(column);
|
||||||
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' } : {};
|
|
||||||
return (
|
return (
|
||||||
<td key={column.key} className={combinedClassName}
|
<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 }}>
|
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 cellValue = row[column.key];
|
||||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
const alignStyle = _columnAlignStyle(column);
|
||||||
// ``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' } : {};
|
|
||||||
return (
|
return (
|
||||||
<td key={column.key} className={combinedClassName}
|
<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 }}>
|
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* actual commit to the parent via `onApply` / `onCancel`.
|
* 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 { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
||||||
import {
|
import {
|
||||||
|
|
@ -200,6 +200,36 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
return () => window.removeEventListener('keydown', _onKey);
|
return () => window.removeEventListener('keydown', _onKey);
|
||||||
}, [draft, onApply, onCancel]);
|
}, [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 (
|
return (
|
||||||
<div ref={popRef} className={styles.popover}>
|
<div ref={popRef} className={styles.popover}>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ export interface Invitation {
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
targetUsername: string;
|
targetUsername: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
createdBy: string;
|
sysCreatedBy: string;
|
||||||
createdAt: number;
|
sysCreatedAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
usedBy?: string;
|
usedBy?: string;
|
||||||
usedAt?: number;
|
usedAt?: number;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||||
import { validateMandateName } from '../utils/mandateNameUtils';
|
import { validateMandateName } from '../utils/mandateNameUtils';
|
||||||
|
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||||
|
import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
||||||
|
|
@ -153,19 +155,21 @@ export function useAdminMandates() {
|
||||||
return await fetchMandateByIdApi(request, mandateId);
|
return await fetchMandateByIdApi(request, mandateId);
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
// Generate columns from attributes (displayField = backend {field}Label for FK columns)
|
// Generate columns from attributes (types merged via resolveColumnTypes)
|
||||||
const columns = attributes.map(attr => ({
|
const columns: ColumnConfig[] = useMemo(() => {
|
||||||
key: attr.name,
|
const raw = attributes.map(attr => ({
|
||||||
label: attr.label || attr.name,
|
key: attr.name,
|
||||||
type: attr.type as any,
|
label: attr.label || attr.name,
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
width: attr.width || 150,
|
width: attr.width || 150,
|
||||||
minWidth: attr.minWidth || 100,
|
minWidth: attr.minWidth || 100,
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
displayField: (attr as any).displayField,
|
displayField: (attr as any).displayField,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes);
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
// Create mandate
|
// Create mandate
|
||||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { formatUnixTimestamp } from '../utils/time';
|
import { formatUnixTimestamp } from '../utils/time';
|
||||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
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 api from '../api';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
||||||
|
|
@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
||||||
|
|
||||||
const _DashboardTab: React.FC = () => {
|
const _DashboardTab: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
|
||||||
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
||||||
|
|
@ -431,6 +435,13 @@ const _DashboardTab: React.FC = () => {
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||||
const lastPaginationParamsRef = useRef<any>(null);
|
const lastPaginationParamsRef = useRef<any>(null);
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'AutoRun')
|
||||||
|
.then(setBackendAttributes)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
const _loadMetrics = useCallback(async () => {
|
const _loadMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -529,11 +540,10 @@ const _DashboardTab: React.FC = () => {
|
||||||
stopped: t('Gestoppt'),
|
stopped: t('Gestoppt'),
|
||||||
}), [t]);
|
}), [t]);
|
||||||
|
|
||||||
const _runColumns: ColumnConfig[] = useMemo(() => [
|
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'workflowLabel',
|
key: 'workflowLabel',
|
||||||
label: t('Workflow'),
|
label: t('Workflow'),
|
||||||
type: 'string',
|
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||||
|
|
@ -541,7 +551,6 @@ const _DashboardTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'mandateId',
|
key: 'mandateId',
|
||||||
label: t('Mandant'),
|
label: t('Mandant'),
|
||||||
type: 'string',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -550,7 +559,6 @@ const _DashboardTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'featureInstanceId',
|
key: 'featureInstanceId',
|
||||||
label: t('Instanz'),
|
label: t('Instanz'),
|
||||||
type: 'string',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -559,7 +567,6 @@ const _DashboardTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: t('Status'),
|
label: t('Status'),
|
||||||
type: 'string',
|
|
||||||
width: 110,
|
width: 110,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -574,7 +581,6 @@ const _DashboardTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'sysCreatedAt',
|
key: 'sysCreatedAt',
|
||||||
label: t('Gestartet'),
|
label: t('Gestartet'),
|
||||||
type: 'number',
|
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
|
|
@ -582,13 +588,17 @@ const _DashboardTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'sysModifiedAt',
|
key: 'sysModifiedAt',
|
||||||
label: t('Beendet'),
|
label: t('Beendet'),
|
||||||
type: 'number',
|
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
], [t, _STATUS_LABELS]);
|
], [t, _STATUS_LABELS]);
|
||||||
|
|
||||||
|
const _runColumns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
||||||
|
[_rawRunColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
const _hookData = useMemo(() => ({
|
const _hookData = useMemo(() => ({
|
||||||
refetch: _loadRuns,
|
refetch: _loadRuns,
|
||||||
pagination: paginationMeta,
|
pagination: paginationMeta,
|
||||||
|
|
@ -711,6 +721,13 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
const lastPaginationParamsRef = useRef<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) => {
|
const _load = useCallback(async (paginationParams?: any) => {
|
||||||
if (paginationParams !== undefined) {
|
if (paginationParams !== undefined) {
|
||||||
|
|
@ -883,12 +900,11 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _columns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
|
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
||||||
{
|
{
|
||||||
key: 'mandateId',
|
key: 'mandateId',
|
||||||
label: t('Mandant'),
|
label: t('Mandant'),
|
||||||
type: 'string',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -897,7 +913,6 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'featureInstanceId',
|
key: 'featureInstanceId',
|
||||||
label: t('Instanz'),
|
label: t('Instanz'),
|
||||||
type: 'string',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -906,7 +921,6 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'active',
|
key: 'active',
|
||||||
label: t('Aktiv'),
|
label: t('Aktiv'),
|
||||||
type: 'boolean',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
|
|
@ -914,13 +928,11 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'isRunning',
|
key: 'isRunning',
|
||||||
label: t('Läuft'),
|
label: t('Läuft'),
|
||||||
type: 'boolean',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sysCreatedAt',
|
key: 'sysCreatedAt',
|
||||||
label: t('Erstellt'),
|
label: t('Erstellt'),
|
||||||
type: 'number',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
|
|
@ -928,19 +940,22 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'lastStartedAt',
|
key: 'lastStartedAt',
|
||||||
label: t('Zuletzt gestartet'),
|
label: t('Zuletzt gestartet'),
|
||||||
type: 'number',
|
|
||||||
width: 160,
|
width: 160,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'runCount',
|
key: 'runCount',
|
||||||
label: t('Läufe'),
|
label: t('Läufe'),
|
||||||
type: 'number',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const _columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
const _hookData = useMemo(() => ({
|
const _hookData = useMemo(() => ({
|
||||||
refetch: _load,
|
refetch: _load,
|
||||||
handleDelete: (id: string) => _handleDelete(id),
|
handleDelete: (id: string) => _handleDelete(id),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
||||||
import api from '../api';
|
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 { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useUserMandates } from '../hooks/useUserMandates';
|
import { useUserMandates } from '../hooks/useUserMandates';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
|
|
@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export const ComplianceAuditPage: React.FC = () => {
|
export const ComplianceAuditPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
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 { fetchMandates } = useUserMandates();
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
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 [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||||
|
|
@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
// ── Column definitions ──
|
// ── Column definitions ──
|
||||||
|
|
||||||
const aiLogColumns: ColumnConfig[] = useMemo(() => [
|
const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
{ key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
|
||||||
{
|
{
|
||||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
key: 'username',
|
||||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
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 || '–',
|
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) => {
|
formatter: (val: any, row: any) => {
|
||||||
const provider = val || '–';
|
const provider = val || '–';
|
||||||
const op = row?.operationType;
|
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,
|
key: 'priceCHF',
|
||||||
formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–',
|
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,
|
key: 'neutralizationActive',
|
||||||
formatter: (val: any) => val ? '✓' : '–',
|
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,
|
key: 'success',
|
||||||
formatter: (val: any) => val ? t('OK') : t('Fehler'),
|
label: t('Status'),
|
||||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 80,
|
||||||
|
formatter: (val: any) => (val ? t('OK') : t('Fehler')),
|
||||||
|
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
const auditLogColumns: ColumnConfig[] = useMemo(() => [
|
const aiLogColumns: ColumnConfig[] = useMemo(
|
||||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
() => 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,
|
key: 'username',
|
||||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
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) => {
|
cellClassName: (val: any) => {
|
||||||
const color = _CATEGORY_COLORS[val as string];
|
const color = _CATEGORY_COLORS[val as string];
|
||||||
return color ? styles[`cat_${val}`] || '' : '';
|
return color ? styles[`cat_${val}`] || '' : '';
|
||||||
},
|
},
|
||||||
formatter: (val: any) => val || '–',
|
formatter: (val: any) => val || '–',
|
||||||
},
|
},
|
||||||
{ key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 },
|
{ key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 },
|
||||||
{ key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
{ key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 },
|
||||||
{ key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 },
|
{ key: 'details', label: t('Details'), searchable: true, width: 250 },
|
||||||
{
|
{
|
||||||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70,
|
key: 'success',
|
||||||
formatter: (val: any) => val ? '✓' : '✗',
|
label: t('Status'),
|
||||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
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]);
|
], [t]);
|
||||||
|
|
||||||
const neutColumns: ColumnConfig[] = useMemo(() => [
|
const auditLogColumns: ColumnConfig[] = useMemo(
|
||||||
{ key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
|
() => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs),
|
||||||
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
|
[_rawAuditLogColumns, auditLogAttrs],
|
||||||
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
);
|
||||||
|
|
||||||
|
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,
|
key: 'username',
|
||||||
formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
|
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,
|
key: 'instanceLabel',
|
||||||
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
|
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,
|
key: 'fileId',
|
||||||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
label: t('Datei'),
|
||||||
|
sortable: true,
|
||||||
|
width: 140,
|
||||||
|
formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const neutColumns: ColumnConfig[] = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawNeutColumns, neutAttrs),
|
||||||
|
[_rawNeutColumns, neutAttrs],
|
||||||
|
);
|
||||||
|
|
||||||
// ── fetchFilterValues for autofilter dropdowns ──
|
// ── fetchFilterValues for autofilter dropdowns ──
|
||||||
|
|
||||||
const _makeFetchFilterValues = useCallback(
|
const _makeFetchFilterValues = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo
|
||||||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
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 { ChatbotConfigSection } from './ChatbotConfigSection';
|
||||||
import { TextField } from '../../components/UiComponents/TextField';
|
import { TextField } from '../../components/UiComponents/TextField';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const { fetchMandates } = useUserMandates();
|
const { fetchMandates } = useUserMandates();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { loadFeatures } = useFeatureStore();
|
const { loadFeatures } = useFeatureStore();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
|
|
@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [selectedMandateId, fetchInstances]);
|
}, [selectedMandateId, fetchInstances]);
|
||||||
|
|
||||||
// Table columns
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => [
|
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
|
||||||
{ 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,
|
key: 'featureCode',
|
||||||
render: (value: string) => {
|
label: t('Feature'),
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 150,
|
||||||
|
formatter: (value: string) => {
|
||||||
const feature = features.find(f => f.code === value);
|
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]);
|
], [features, t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
// Form attributes from backend - merge with dynamic feature options
|
// Form attributes from backend - merge with dynamic feature options
|
||||||
// Exclude featureCode, config, and label since we handle them separately
|
// Exclude featureCode, config, and label since we handle them separately
|
||||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useFeatureStore } from '../../stores/featureStore';
|
import { useFeatureStore } from '../../stores/featureStore';
|
||||||
import api from '../../api';
|
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 styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
const { fetchMandates } = useUserMandates();
|
const { fetchMandates } = useUserMandates();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { loadFeatures } = useFeatureStore();
|
const { loadFeatures } = useFeatureStore();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
// Combined instance option type
|
// Combined instance option type
|
||||||
interface CombinedInstanceOption {
|
interface CombinedInstanceOption {
|
||||||
|
|
@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
return selectedCombinedKey.split(':')[1] || '';
|
return selectedCombinedKey.split(':')[1] || '';
|
||||||
}, [selectedCombinedKey]);
|
}, [selectedCombinedKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'FeatureAccessView')
|
||||||
|
.then(setBackendAttributes)
|
||||||
|
.catch(() => setBackendAttributes([]));
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
// Load mandates and features on mount, then build combined options
|
// Load mandates and features on mount, then build combined options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeatures();
|
fetchFeatures();
|
||||||
|
|
@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||||
}, [allUsers, instanceUsers]);
|
}, [allUsers, instanceUsers]);
|
||||||
|
|
||||||
// Table columns
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => [
|
|
||||||
{
|
{
|
||||||
key: 'username',
|
key: 'username',
|
||||||
label: t('Benutzername'),
|
label: t('Benutzername'),
|
||||||
type: 'text' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: t('E-Mail'),
|
label: t('E-Mail'),
|
||||||
type: 'text' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'fullName',
|
key: 'fullName',
|
||||||
label: t('Vollständiger Name'),
|
label: t('Vollständiger Name'),
|
||||||
type: 'text' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'roleLabels',
|
key: 'roleLabels',
|
||||||
label: t('Rollen'),
|
label: t('Rollen'),
|
||||||
type: 'text' as const,
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (value: string[]) => {
|
formatter: (value: string[]) => {
|
||||||
if (!value || value.length === 0) return '-';
|
if (!value || value.length === 0) return '-';
|
||||||
return value.join(', ');
|
return value.join(', ');
|
||||||
},
|
},
|
||||||
|
|
@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
label: t('Aktiv'),
|
label: t('Aktiv'),
|
||||||
type: 'boolean' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: false,
|
searchable: false,
|
||||||
|
|
@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
// Dynamic options for forms (users and roles)
|
// Dynamic options for forms (users and roles)
|
||||||
const userOptions = useMemo(() =>
|
const userOptions = useMemo(() =>
|
||||||
availableUsers.map(u => ({
|
availableUsers.map(u => ({
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules';
|
||||||
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
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 styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [roleTableAttributes, setRoleTableAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [features, setFeatures] = useState<Feature[]>([]);
|
const [features, setFeatures] = useState<Feature[]>([]);
|
||||||
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
|
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
|
||||||
|
|
@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'Role')
|
||||||
|
.then(setRoleTableAttributes)
|
||||||
|
.catch(() => setRoleTableAttributes([]));
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
// Load features on mount
|
// Load features on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFeatures = async () => {
|
const loadFeatures = async () => {
|
||||||
|
|
@ -130,29 +143,25 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Table columns
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => [
|
|
||||||
{
|
{
|
||||||
key: 'roleLabel',
|
key: 'roleLabel',
|
||||||
label: t('Rollen-Label'),
|
label: t('Rollen-Label'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
width: 180
|
width: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
label: t('Beschreibung'),
|
label: t('Beschreibung'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
width: 300,
|
width: 300,
|
||||||
formatter: (value: string) => getTextValue(value)
|
formatter: (value: string) => getTextValue(value),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'featureCode',
|
key: 'featureCode',
|
||||||
label: t('Feature'),
|
label: t('Feature'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|
@ -160,10 +169,15 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
|
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
|
||||||
<FaCube style={{ marginRight: 4 }} /> {value}
|
<FaCube style={{ marginRight: 4 }} /> {value}
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, roleTableAttributes),
|
||||||
|
[_rawColumns, roleTableAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
// Form attributes for create
|
// Form attributes for create
|
||||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||||
const fields: AttributeDefinition[] = [
|
const fields: AttributeDefinition[] = [
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
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 styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
const { request } = useApiRequest();
|
||||||
const {
|
const {
|
||||||
invitations,
|
invitations,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMandates();
|
loadMandates();
|
||||||
// Fetch Invitation attributes from backend
|
fetchAttributes(request, 'Invitation')
|
||||||
api.get('/api/attributes/Invitation').then(response => {
|
.then(setBackendAttributes)
|
||||||
const attrs = response.data?.attributes || response.data || [];
|
.catch(() => setBackendAttributes([]));
|
||||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
}, [fetchMandates, request]);
|
||||||
}).catch(() => setBackendAttributes([]));
|
|
||||||
}, [fetchMandates]);
|
|
||||||
|
|
||||||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -84,12 +86,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Table columns
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => [
|
|
||||||
{
|
{
|
||||||
key: 'targetUsername',
|
key: 'targetUsername',
|
||||||
label: t('Benutzername'),
|
label: t('Benutzername'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -98,11 +98,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: t('E-Mail'),
|
label: t('E-Mail'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (value: string, row: Invitation) => {
|
formatter: (value: string, row: Invitation) => {
|
||||||
const emailText = value || '-';
|
const emailText = value || '-';
|
||||||
const emailSent = (row as any).emailSent;
|
const emailSent = (row as any).emailSent;
|
||||||
return (
|
return (
|
||||||
|
|
@ -110,30 +109,28 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{emailText} {emailSent && '✓'}
|
{emailText} {emailSent && '✓'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'roleIds',
|
key: 'roleIds',
|
||||||
label: t('Rollen'),
|
label: t('Rollen'),
|
||||||
type: 'string', // Array rendered as string
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (value: string[]) => {
|
formatter: (value: string[]) => {
|
||||||
if (!value || value.length === 0) return '-';
|
if (!value || value.length === 0) return '-';
|
||||||
return value.map(roleId => {
|
return value.map((roleId) => {
|
||||||
const role = roles.find(r => r.id === roleId);
|
const role = roles.find(r => r.id === roleId);
|
||||||
return role?.roleLabel || roleId;
|
return role?.roleLabel || roleId;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
}
|
},
|
||||||
} as any,
|
},
|
||||||
{
|
{
|
||||||
key: 'expiresAt',
|
key: 'expiresAt',
|
||||||
label: t('Gültig bis'),
|
label: t('Gültig bis'),
|
||||||
type: 'number' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (value: number) => {
|
formatter: (value: number) => {
|
||||||
const text = formatDate(value);
|
const text = formatDate(value);
|
||||||
const isExpired = value < Date.now() / 1000;
|
const isExpired = value < Date.now() / 1000;
|
||||||
return (
|
return (
|
||||||
|
|
@ -141,29 +138,32 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{text} {isExpired && '(abgelaufen)'}
|
{text} {isExpired && '(abgelaufen)'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'currentUses',
|
key: 'currentUses',
|
||||||
label: t('Verwendet'),
|
label: t('Verwendet'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 100,
|
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'),
|
label: t('Erstellt'),
|
||||||
type: 'number' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (value: number) => formatDate(value)
|
formatter: (value: number) => formatDate(value),
|
||||||
},
|
},
|
||||||
], [roles, t]);
|
], [roles, t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
||||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
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
|
// Mandate-level roles (user, viewer, admin) - same as when adding mandate members
|
||||||
const roleOptions = roles
|
const roleOptions = roles
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import api from '../../api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
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 { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -39,44 +43,6 @@ type ProgressInfo = {
|
||||||
keysTranslated?: number;
|
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
|
// 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 --
|
// 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
|
// 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 = () => {
|
export const AdminLanguagesPage: React.FC = () => {
|
||||||
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [langSetAttributes, setLangSetAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
const [rows, setRows] = useState<LangRow[]>([]);
|
const [rows, setRows] = useState<LangRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -288,6 +256,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
const busyRef = useRef(false);
|
const busyRef = useRef(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'UiLanguageSetView')
|
||||||
|
.then(setLangSetAttributes)
|
||||||
|
.catch(() => setLangSetAttributes([]));
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -393,6 +367,44 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
}, [rows, search]);
|
}, [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 existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||||
|
|
||||||
const addChoices = useMemo(() => {
|
const addChoices = useMemo(() => {
|
||||||
|
|
@ -869,7 +881,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={displayRows}
|
data={displayRows}
|
||||||
columns={_getColumns(t)}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
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 styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -31,6 +34,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
const { t, currentLanguage } = useLanguage();
|
const { t, currentLanguage } = useLanguage();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { request } = useApiRequest();
|
||||||
const { showError, showWarning } = useToast();
|
const { showError, showWarning } = useToast();
|
||||||
const {
|
const {
|
||||||
roles,
|
roles,
|
||||||
|
|
@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMandates();
|
loadMandates();
|
||||||
// Fetch Role attributes from backend
|
fetchAttributes(request, 'Role')
|
||||||
api.get('/api/attributes/Role').then(response => {
|
.then(setBackendAttributes)
|
||||||
const attrs = response.data?.attributes || response.data || [];
|
.catch(() => setBackendAttributes([]));
|
||||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
}, [fetchMandates, request]);
|
||||||
}).catch(() => setBackendAttributes([]));
|
|
||||||
}, [fetchMandates]);
|
|
||||||
|
|
||||||
// Load roles when mandate or scopeFilter changes
|
// Load roles when mandate or scopeFilter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -102,30 +104,26 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
return String(desc);
|
return String(desc);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Table columns - scopeType is now a backend-computed field
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => [
|
|
||||||
{
|
{
|
||||||
key: 'roleLabel',
|
key: 'roleLabel',
|
||||||
label: t('Bezeichnung'),
|
label: t('Bezeichnung'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
width: 150
|
width: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
label: t('Beschreibung'),
|
label: t('Beschreibung'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
width: 250,
|
width: 250,
|
||||||
formatter: (value: string) => getDescriptionText(value)
|
formatter: (value: string) => getDescriptionText(value),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'scopeType',
|
key: 'scopeType',
|
||||||
label: t('Geltungsbereich'),
|
label: t('Geltungsbereich'),
|
||||||
type: 'string' as const,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
width: 140,
|
width: 140,
|
||||||
|
|
@ -149,10 +147,15 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
|
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
// Form attributes from backend - for create form
|
// Form attributes from backend - for create form
|
||||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
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 styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
const { request } = useApiRequest();
|
||||||
const {
|
const {
|
||||||
users,
|
users,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMandates();
|
loadMandates();
|
||||||
// Fetch UserMandate attributes from backend (for table columns)
|
fetchAttributes(request, 'UserMandateView')
|
||||||
api.get('/api/attributes/UserMandate').then(response => {
|
.then(setBackendAttributes)
|
||||||
const attrs = response.data?.attributes || response.data || [];
|
.catch(() => setBackendAttributes([]));
|
||||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
}, [fetchMandates, request]);
|
||||||
}).catch(() => setBackendAttributes([]));
|
|
||||||
}, [fetchMandates]);
|
|
||||||
|
|
||||||
// Load users when mandate changes
|
// Load users when mandate changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -97,60 +99,57 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||||
}, [allUsers, users]);
|
}, [allUsers, users]);
|
||||||
|
|
||||||
// Table columns - based on MandateUserInfo response structure
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns = useMemo(() => {
|
{
|
||||||
return [
|
key: 'username',
|
||||||
{
|
label: t('Benutzername'),
|
||||||
key: 'username',
|
sortable: true,
|
||||||
label: t('Benutzername'),
|
filterable: true,
|
||||||
type: 'text' as any,
|
searchable: true,
|
||||||
sortable: true,
|
width: 150,
|
||||||
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'),
|
key: 'enabled',
|
||||||
type: 'text' as any,
|
label: t('Aktiv'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: false,
|
||||||
width: 200,
|
width: 80,
|
||||||
},
|
},
|
||||||
{
|
], [t]);
|
||||||
key: 'fullName',
|
|
||||||
label: t('Vollständiger Name'),
|
const columns = useMemo(
|
||||||
type: 'text' as any,
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
sortable: true,
|
[_rawColumns, backendAttributes],
|
||||||
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]);
|
|
||||||
|
|
||||||
// Dynamic options for forms (users and roles)
|
// Dynamic options for forms (users and roles)
|
||||||
const userOptions = useMemo(() =>
|
const userOptions = useMemo(() =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import styles from './Admin.module.css';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
|
||||||
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
||||||
|
|
||||||
|
|
@ -57,12 +58,11 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
// Generate columns from attributes
|
// Generate columns from attributes; types from backend via resolveColumnTypes
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return (attributes || []).map(attr => ({
|
const raw = (attributes || []).map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -71,6 +71,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
displayField: (attr as any).displayField,
|
displayField: (attr as any).displayField,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes || []);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const ConnectionsPage: React.FC = () => {
|
export const ConnectionsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -54,13 +55,12 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
|
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
|
||||||
|
|
||||||
return (attributes || [])
|
const raw = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
const col: any = {
|
const col: any = {
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -71,13 +71,9 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
frontendFormat: (attr as any).frontendFormat,
|
frontendFormat: (attr as any).frontendFormat,
|
||||||
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (attr.name === 'userId') {
|
|
||||||
col.label = t('Benutzer');
|
|
||||||
}
|
|
||||||
|
|
||||||
return col;
|
return col;
|
||||||
});
|
});
|
||||||
|
return resolveColumnTypes(raw, attributes || []);
|
||||||
}, [attributes, t]);
|
}, [attributes, t]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
|
||||||
interface UserFile {
|
interface UserFile {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -203,7 +204,6 @@ export const FilesPage: React.FC = () => {
|
||||||
.map(attr => ({
|
.map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -217,7 +217,6 @@ export const FilesPage: React.FC = () => {
|
||||||
cols.push({
|
cols.push({
|
||||||
key: 'sysCreatedBy',
|
key: 'sysCreatedBy',
|
||||||
label: t('Erstellt von'),
|
label: t('Erstellt von'),
|
||||||
type: 'text' as any,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -226,7 +225,7 @@ export const FilesPage: React.FC = () => {
|
||||||
maxWidth: 250,
|
maxWidth: 250,
|
||||||
displayField: 'sysCreatedByLabel',
|
displayField: 'sysCreatedByLabel',
|
||||||
} as any);
|
} as any);
|
||||||
return cols;
|
return resolveColumnTypes(cols, attributes || []);
|
||||||
}, [attributes, t]);
|
}, [attributes, t]);
|
||||||
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
|
||||||
interface Prompt {
|
interface Prompt {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -76,7 +77,6 @@ export const PromptsPage: React.FC = () => {
|
||||||
.map(attr => ({
|
.map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -92,7 +92,6 @@ export const PromptsPage: React.FC = () => {
|
||||||
cols.push({
|
cols.push({
|
||||||
key: 'sysCreatedBy',
|
key: 'sysCreatedBy',
|
||||||
label: t('Erstellt von'),
|
label: t('Erstellt von'),
|
||||||
type: 'text' as any,
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
|
|
@ -104,7 +103,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
frontendFormatLabels: undefined,
|
frontendFormatLabels: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return cols;
|
return resolveColumnTypes(cols, attributes || []);
|
||||||
}, [attributes, t]);
|
}, [attributes, t]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|
|
||||||
|
|
@ -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 { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
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 api from '../../api';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
|
@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
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 AdminSubscriptionsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
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 _handleForceCancel = useCallback(async (row: any) => {
|
||||||
const ok = await confirm(
|
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 }),
|
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) {
|
} catch (err) {
|
||||||
console.error('Force cancel failed:', err);
|
console.error('Force cancel failed:', err);
|
||||||
}
|
}
|
||||||
}, [confirm, refetch]);
|
}, [confirm, refetch, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||||
|
|
@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={subscriptions}
|
data={subscriptions}
|
||||||
columns={_getColumns(t)}
|
columns={columns}
|
||||||
apiEndpoint="/api/subscription/admin/all"
|
apiEndpoint="/api/subscription/admin/all"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import api from '../../api';
|
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 { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
|
|
@ -252,6 +256,8 @@ function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'ba
|
||||||
|
|
||||||
export const BillingDataView: React.FC = () => {
|
export const BillingDataView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [billingTxnAttributes, setBillingTxnAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
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 [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(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
|
// 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
|
// "nur meine Daten" is an additional filter on top of the dropdown scope
|
||||||
const _scopeParams = useMemo((): Record<string, string> => {
|
const _scopeParams = useMemo((): Record<string, string> => {
|
||||||
|
|
@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => {
|
||||||
fetchFilterValues: _fetchTransactionFilterValues,
|
fetchFilterValues: _fetchTransactionFilterValues,
|
||||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
||||||
|
|
||||||
// Table column definitions
|
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
||||||
const columns: ColumnConfig[] = useMemo(() => [
|
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
||||||
{ key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 },
|
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 },
|
||||||
{ key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
{ key: 'userName', label: t('Benutzer'), 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'), sortable: true, filterable: true, width: 100 },
|
||||||
{ key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
{ key: 'description', label: t('Beschreibung'), searchable: true, width: 250 },
|
||||||
{ key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 },
|
{ key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 },
|
||||||
{ key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
{ key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 },
|
||||||
{ key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
{ key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 },
|
||||||
{ key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
{ key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 },
|
||||||
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
|
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes),
|
||||||
|
[_rawTransactionColumns, billingTxnAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
const totalBalance = useMemo(() => {
|
const totalBalance = useMemo(() => {
|
||||||
const filtered = selectedScope === 'personal' || selectedScope === 'all'
|
const filtered = selectedScope === 'personal' || selectedScope === 'all'
|
||||||
? balances
|
? balances
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
||||||
<tbody>
|
<tbody>
|
||||||
{transactions.map((txn) => (
|
{transactions.map((txn) => (
|
||||||
<tr key={txn.id}>
|
<tr key={txn.id}>
|
||||||
<td>{formatDate(txn.createdAt)}</td>
|
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||||
<td>{txn.mandateName || '-'}</td>
|
<td>{txn.mandateName || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>
|
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{formatDate(transaction.createdAt)}</td>
|
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
||||||
<td>{transaction.mandateName || '-'}</td>
|
<td>{transaction.mandateName || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredTransactions.map((txn) => (
|
{filteredTransactions.map((txn) => (
|
||||||
<tr key={txn.id}>
|
<tr key={txn.id}>
|
||||||
<td>{formatDate(txn.createdAt)}</td>
|
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||||
<td>{txn.mandateName || '-'}</td>
|
<td>{txn.mandateName || '-'}</td>
|
||||||
<td>{txn.userName || '-'}</td>
|
<td>{txn.userName || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import {
|
||||||
type AutoWorkflowTemplate,
|
type AutoWorkflowTemplate,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { useToast } from '../../../contexts/ToastContext';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
import styles from '../../../pages/admin/Admin.module.css';
|
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 [sharingId, setSharingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(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) => {
|
const load = useCallback(async (paginationParams?: any) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -173,20 +183,18 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
[mandateId, instanceId, navigate]
|
[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',
|
key: 'templateScope',
|
||||||
label: t('Bereich'),
|
label: t('Bereich'),
|
||||||
type: 'string',
|
|
||||||
width: 100,
|
width: 100,
|
||||||
formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
|
formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sharedReadOnly',
|
key: 'sharedReadOnly',
|
||||||
label: t('Freigegeben'),
|
label: t('Freigegeben'),
|
||||||
type: 'boolean',
|
|
||||||
width: 100,
|
width: 100,
|
||||||
formatter: (v: boolean) =>
|
formatter: (v: boolean) =>
|
||||||
v ? (
|
v ? (
|
||||||
|
|
@ -198,14 +206,12 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'sysCreatedBy',
|
key: 'sysCreatedBy',
|
||||||
label: t('Erstellt von'),
|
label: t('Erstellt von'),
|
||||||
type: 'string',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
displayField: 'sysCreatedByLabel',
|
displayField: 'sysCreatedByLabel',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sysCreatedAt',
|
key: 'sysCreatedAt',
|
||||||
label: t('Erstellt'),
|
label: t('Erstellt'),
|
||||||
type: 'number',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
|
|
@ -213,6 +219,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
[t, scopeLabels],
|
[t, scopeLabels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
* 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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
|
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
|
|
@ -26,6 +26,9 @@ import {
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type WorkflowFileEnvelope,
|
type WorkflowFileEnvelope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { useToast } from '../../../contexts/ToastContext';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
import styles from '../../../pages/admin/Admin.module.css';
|
import styles from '../../../pages/admin/Admin.module.css';
|
||||||
|
|
@ -64,6 +67,13 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'Automation2Workflow')
|
||||||
|
.then(setBackendAttributes)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
const load = useCallback(async (paginationParams?: any) => {
|
const load = useCallback(async (paginationParams?: any) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -251,12 +261,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
[instanceId, request, showSuccess, showError, load, t],
|
[instanceId, request, showSuccess, showError, load, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnConfig[] = [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
{ key: 'label', label: t('Workflow'), width: 200, sortable: true },
|
||||||
{
|
{
|
||||||
key: 'active',
|
key: 'active',
|
||||||
label: t('Aktiv (Spalte)'),
|
label: t('Aktiv (Spalte)'),
|
||||||
type: 'boolean',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
formatter: (value: boolean) =>
|
formatter: (value: boolean) =>
|
||||||
value !== false ? (
|
value !== false ? (
|
||||||
|
|
@ -268,7 +277,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'isRunning',
|
key: 'isRunning',
|
||||||
label: t('läuft'),
|
label: t('läuft'),
|
||||||
type: 'boolean',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
formatter: (value: boolean) =>
|
formatter: (value: boolean) =>
|
||||||
value ? (
|
value ? (
|
||||||
|
|
@ -280,7 +288,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'stuckAtNodeLabel',
|
key: 'stuckAtNodeLabel',
|
||||||
label: t('steht bei'),
|
label: t('steht bei'),
|
||||||
type: 'string',
|
|
||||||
width: 160,
|
width: 160,
|
||||||
formatter: (value: string, row: Automation2Workflow) =>
|
formatter: (value: string, row: Automation2Workflow) =>
|
||||||
row.isRunning && (value || row.stuckAtNodeId)
|
row.isRunning && (value || row.stuckAtNodeId)
|
||||||
|
|
@ -290,25 +297,27 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
label: t('Erstellt'),
|
label: t('Erstellt'),
|
||||||
type: 'number',
|
|
||||||
width: 140,
|
width: 140,
|
||||||
formatter: (v: number) => formatTs(v),
|
formatter: (v: number) => formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'lastStartedAt',
|
key: 'lastStartedAt',
|
||||||
label: t('zuletzt gestartet'),
|
label: t('zuletzt gestartet'),
|
||||||
type: 'number',
|
|
||||||
width: 160,
|
width: 160,
|
||||||
formatter: (v: number) => formatTs(v),
|
formatter: (v: number) => formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'runCount',
|
key: 'runCount',
|
||||||
label: t('Läufe'),
|
label: t('Läufe'),
|
||||||
type: 'number',
|
|
||||||
width: 80,
|
width: 80,
|
||||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
},
|
},
|
||||||
];
|
], [t]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
[_rawColumns, backendAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
const hookData = {
|
const hookData = {
|
||||||
refetch: load,
|
refetch: load,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const RealEstateParcelsView: React.FC = () => {
|
export const RealEstateParcelsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -54,10 +55,9 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
}, [instanceId, refetch]);
|
}, [instanceId, refetch]);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return (attributes || []).map(attr => ({
|
const raw = (attributes || []).map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -66,6 +66,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
displayField: (attr as any).displayField,
|
displayField: (attr as any).displayField,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes || []);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const RealEstateProjectsView: React.FC = () => {
|
export const RealEstateProjectsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -52,10 +53,9 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
}, [instanceId, refetch]);
|
}, [instanceId, refetch]);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return (attributes || []).map(attr => ({
|
const raw = (attributes || []).map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -64,6 +64,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
displayField: (attr as any).displayField,
|
displayField: (attr as any).displayField,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes || []);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import api from '../../../api';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const TrusteeDocumentsView: React.FC = () => {
|
export const TrusteeDocumentsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -70,7 +71,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
const allCols = (attributes || []).map(attr => ({
|
const allCols = (attributes || []).map(attr => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -88,7 +88,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
for (const col of allCols) {
|
for (const col of allCols) {
|
||||||
if (byKey.has(col.key)) ordered.push(col);
|
if (byKey.has(col.key)) ordered.push(col);
|
||||||
}
|
}
|
||||||
return ordered;
|
return resolveColumnTypes(ordered, attributes || []);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const TrusteePositionDocumentsView: React.FC = () => {
|
export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -56,12 +57,11 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
// Exclude system fields from table columns
|
// Exclude system fields from table columns
|
||||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
|
|
||||||
return attributes
|
const raw = attributes
|
||||||
.filter((attr: any) => !excludedFields.includes(attr.name))
|
.filter((attr: any) => !excludedFields.includes(attr.name))
|
||||||
.map((attr: any) => ({
|
.map((attr: any) => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -70,6 +70,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
displayField: attr.displayField,
|
displayField: attr.displayField,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
// Check permissions (general level)
|
// Check permissions (general level)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { formatAmount, formatPercent } from '../../../utils/formatAmount';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export const TrusteePositionsView: React.FC = () => {
|
export const TrusteePositionsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -285,7 +286,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
const col: ColumnConfig = {
|
const col: ColumnConfig = {
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -320,7 +320,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
const col = byKey.get(key);
|
const col = byKey.get(key);
|
||||||
if (col) ordered.push(col);
|
if (col) ordered.push(col);
|
||||||
}
|
}
|
||||||
return ordered;
|
return resolveColumnTypes(ordered, attributes || []);
|
||||||
}, [attributes, belegeColumn, syncStatusColumn]);
|
}, [attributes, belegeColumn, syncStatusColumn]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGene
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
|
||||||
import adminStyles from '../../../admin/Admin.module.css';
|
import adminStyles from '../../../admin/Admin.module.css';
|
||||||
|
import { resolveColumnTypes } from '../../../../utils/columnTypeResolver';
|
||||||
|
|
||||||
export interface TrusteeDataTabProps {
|
export interface TrusteeDataTabProps {
|
||||||
/** Result of the entity hook factory call (see `useTrustee.ts`). */
|
/** Result of the entity hook factory call (see `useTrustee.ts`). */
|
||||||
|
|
@ -117,12 +118,11 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
|
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
|
||||||
return (attributes || [])
|
const raw = (attributes || [])
|
||||||
.filter((attr: any) => !hidden.has(attr.name))
|
.filter((attr: any) => !hidden.has(attr.name))
|
||||||
.map((attr: any) => ({
|
.map((attr: any) => ({
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: (attr.type as any) || 'text',
|
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
|
|
@ -133,6 +133,7 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
||||||
frontendFormat: attr.frontendFormat,
|
frontendFormat: attr.frontendFormat,
|
||||||
frontendFormatLabels: attr.frontendFormatLabels,
|
frontendFormatLabels: attr.frontendFormatLabels,
|
||||||
}));
|
}));
|
||||||
|
return resolveColumnTypes(raw, attributes || []);
|
||||||
}, [attributes, hiddenColumns]);
|
}, [attributes, hiddenColumns]);
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ export type AttributeType =
|
||||||
| 'string'
|
| 'string'
|
||||||
| 'enum'
|
| 'enum'
|
||||||
| 'slug'
|
| 'slug'
|
||||||
| 'readonly';
|
| 'readonly'
|
||||||
|
| 'object'
|
||||||
|
| 'json';
|
||||||
|
|
||||||
export type InputComponentType =
|
export type InputComponentType =
|
||||||
| 'text'
|
| 'text'
|
||||||
|
|
@ -82,6 +84,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
||||||
return 'multiselect';
|
return 'multiselect';
|
||||||
|
|
||||||
case 'integer':
|
case 'integer':
|
||||||
|
case 'int':
|
||||||
case 'number':
|
case 'number':
|
||||||
case 'float':
|
case 'float':
|
||||||
return 'number';
|
return 'number';
|
||||||
|
|
@ -114,6 +117,10 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
||||||
case 'readonly':
|
case 'readonly':
|
||||||
return 'text'; // Default to text for readonly, but should be rendered as readonly
|
return 'text'; // Default to text for readonly, but should be rendered as readonly
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
case 'json':
|
||||||
|
return 'textarea';
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Default fallback to text input
|
// Default fallback to text input
|
||||||
return 'text';
|
return 'text';
|
||||||
|
|
@ -124,7 +131,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
||||||
* Determines if an attribute type should render as a textarea
|
* Determines if an attribute type should render as a textarea
|
||||||
*/
|
*/
|
||||||
export function isTextareaType(attributeType: AttributeType): boolean {
|
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
|
* Determines if an attribute type should render as a number input
|
||||||
*/
|
*/
|
||||||
export function isNumberType(attributeType: AttributeType): boolean {
|
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';
|
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
|
* Gets the default value for an attribute type
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
57
src/utils/columnTypeResolver.ts
Normal file
57
src/utils/columnTypeResolver.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue