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 type { AttributeType } from '../utils/attributeTypeMapper';
|
||||
|
||||
export type { AttributeType };
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
|
|
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
|
||||
type: AttributeType;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export interface BillingTransaction {
|
|||
aicoreProvider?: string;
|
||||
aicoreModel?: string;
|
||||
createdByUserId?: string;
|
||||
createdAt?: string;
|
||||
sysCreatedAt?: string;
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
userId?: string;
|
||||
|
|
|
|||
|
|
@ -2,45 +2,17 @@
|
|||
* Form node config - draggable fields, types, required toggle
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const fields = (params.fields as FormField[]) ?? [];
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !request) {
|
||||
setConnections([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setConnectionsLoading(true);
|
||||
fetchConnections(request, instanceId)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setConnections([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setConnectionsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [instanceId, request]);
|
||||
|
||||
const moveField = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
||||
|
|
@ -108,33 +80,17 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
</div>
|
||||
<div className={styles.formFieldRowFooter}>
|
||||
<select
|
||||
value={f.type ?? 'string'}
|
||||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const fieldType = e.target.value;
|
||||
next[i] = {
|
||||
...next[i],
|
||||
type: fieldType,
|
||||
...(fieldType === 'clickup_tasks'
|
||||
? { clickupStatusOptions: undefined }
|
||||
: fieldType === 'clickup_status'
|
||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
||||
: {
|
||||
clickupConnectionId: undefined,
|
||||
clickupListId: undefined,
|
||||
clickupStatusOptions: undefined,
|
||||
}),
|
||||
};
|
||||
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
>
|
||||
<option value="string">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Kontrollkästchen')}</option>
|
||||
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={styles.formFieldRequiredLabel}>
|
||||
<input
|
||||
|
|
@ -157,72 +113,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
{f.type === 'clickup_status' ? (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
||||
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
{t(
|
||||
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
|
||||
{ count: String(f.clickupStatusOptions.length) }
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
{t(
|
||||
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{f.type === 'clickup_tasks' ? (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
{t('ClickUp-Verbindung')}
|
||||
</label>
|
||||
<select
|
||||
value={f.clickupConnectionId ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], clickupConnectionId: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
disabled={connectionsLoading || !instanceId}
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
>
|
||||
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalUsername ?? c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
|
||||
</label>
|
||||
<input
|
||||
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
|
||||
value={f.clickupListId ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], clickupListId: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
||||
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
|
||||
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
|
||||
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
|
||||
}
|
||||
>
|
||||
+ {t('Feld')}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import type { ComponentType } from 'react';
|
||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
param: NodeTypeParameter;
|
||||
|
|
@ -547,12 +548,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
||||
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="checkbox">{t('Kontrollkästchen')}</option>
|
||||
<option value="select">{t('Auswahl')}</option>
|
||||
<option value="textarea">{t('Mehrzeilig')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
<option value="group">{t('Gruppe')}</option>
|
||||
</select>
|
||||
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
|
|
@ -585,8 +583,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
|||
}}
|
||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -358,8 +358,6 @@ function getFormFieldType(
|
|||
if (rawFieldType === 'email') return 'email';
|
||||
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||||
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||||
if (rawFieldType === 'clickup_tasks') return 'string';
|
||||
if (rawFieldType === 'clickup_status') return 'string';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { AttributeType } from '../../../../utils/attributeTypeMapper';
|
||||
|
||||
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
|
||||
/** input.form / trigger.form field row. */
|
||||
export type FormField = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
type?: AttributeType;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
clickupConnectionId?: string;
|
||||
clickupListId?: string;
|
||||
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
|
||||
clickupStatusOptions?: Array<{ value: string; label: string }>;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export interface NodeConfigRendererProps {
|
||||
|
|
|
|||
|
|
@ -4,40 +4,23 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import type { FormField } from '../shared/types';
|
||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
type FormField = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
|
||||
statusOptions?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
|
||||
|
||||
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
|
||||
const raw = params.formFields;
|
||||
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
|
||||
return raw.map((f, i) => {
|
||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||
const o = f as Record<string, unknown>;
|
||||
const fieldType = String(o.type ?? 'text');
|
||||
const rawType = String(o.type ?? 'text');
|
||||
const name = String(o.name ?? `field${i + 1}`);
|
||||
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||
const type = (
|
||||
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
|
||||
) as FormField['type'];
|
||||
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: 'clickup_status',
|
||||
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
|
||||
};
|
||||
}
|
||||
return { name, label, type };
|
||||
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
|
||||
return { name, label, type } as FormField;
|
||||
}
|
||||
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
|
||||
});
|
||||
|
|
@ -64,7 +47,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder={t('Name (Payload-Key)')}
|
||||
value={f.name}
|
||||
value={f.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...f, name: e.target.value };
|
||||
|
|
@ -74,7 +57,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder={t('Beschriftung')}
|
||||
value={f.label}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...f, label: e.target.value };
|
||||
|
|
@ -83,24 +66,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
/>
|
||||
<select
|
||||
className={styles.startsSelect}
|
||||
value={f.type}
|
||||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const fieldType = e.target.value as FormField['type'];
|
||||
if (fieldType === 'clickup_status') {
|
||||
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
|
||||
} else {
|
||||
next[idx] = { name: f.name, label: f.label, type: fieldType };
|
||||
}
|
||||
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
|
||||
setFields(next);
|
||||
}}
|
||||
>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="email">{t('E-Mail')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Ja/Nein')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
{FORM_FIELD_TYPES.map(ft => (
|
||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -133,20 +133,26 @@
|
|||
}
|
||||
|
||||
.table thead tr {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
.th {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary, #475569);
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
border-bottom: 2px solid var(--color-border, #e2e8f0);
|
||||
border-bottom: 2px solid rgba(124, 109, 216, 0.35);
|
||||
border-right: 1px solid #dde2ea;
|
||||
}
|
||||
|
||||
.th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.th.actionsColumn {
|
||||
|
|
@ -159,14 +165,13 @@
|
|||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: #eef0f3;
|
||||
background: #e4e8ef;
|
||||
color: var(--color-text, #334155);
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
|
@ -230,8 +235,8 @@
|
|||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
|
|
@ -303,6 +308,116 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Numeric column filter (operator + value / range) */
|
||||
.filterNumericPanel {
|
||||
padding: 6px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterNumericRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filterNumericLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.filterOperatorSelect,
|
||||
.filterNumericInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-family);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filterOperatorSelect:focus,
|
||||
.filterNumericInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
|
||||
}
|
||||
|
||||
.filterNumericActions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.filterApplyBtn {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--color-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filterApplyBtn:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* PeriodPicker wrapper inside filter dropdown (date columns).
|
||||
Rendered as sibling to .filterDropdownOptions so the PeriodPicker
|
||||
popover (position: absolute, ~720 px) is not clipped by overflow. */
|
||||
.filterDatePickerWrap {
|
||||
padding: 6px 8px 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
.filterDatePickerWrap + .filterDropdownOptions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Date column filter (from / to) — legacy fallback */
|
||||
.filterDatePanel {
|
||||
padding: 6px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterDateRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filterDateLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.filterDateInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-family);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filterDateInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -326,7 +441,8 @@
|
|||
/* Table cells */
|
||||
.td {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border, #f1f5f9);
|
||||
border-top: 1px solid var(--color-border, #e5e9ef);
|
||||
border-right: 1px solid #eef0f4;
|
||||
color: var(--color-text);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
|
@ -338,21 +454,27 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.tr {
|
||||
transition: background-color 0.12s ease;
|
||||
transition: background-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: var(--color-gray-disabled, #f8fafc);
|
||||
background: #f0f4ff;
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.tr:nth-child(even):hover {
|
||||
background: var(--color-gray-disabled, #f8fafc);
|
||||
background: #f0f4ff;
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr.selected {
|
||||
|
|
@ -372,7 +494,7 @@
|
|||
}
|
||||
|
||||
thead .selectColumn {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
tbody .selectColumn {
|
||||
|
|
@ -423,7 +545,7 @@ tbody .selectColumn {
|
|||
}
|
||||
|
||||
thead .actionsColumn {
|
||||
background: var(--table-header-bg, #f8f9fa);
|
||||
background: var(--table-header-bg, #edf0f5);
|
||||
}
|
||||
|
||||
tbody .actionsColumn {
|
||||
|
|
@ -708,7 +830,11 @@ tbody .actionsColumn {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.th,
|
||||
.th {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
|
|
@ -758,29 +884,40 @@ tbody .actionsColumn {
|
|||
/* Dark theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.table thead tr {
|
||||
background: #2a2d31;
|
||||
background: #2d3038;
|
||||
}
|
||||
|
||||
.th {
|
||||
background: #2a2d31;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
background: #2d3038;
|
||||
border-bottom: 2px solid rgba(124, 109, 216, 0.3);
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.td {
|
||||
border-right-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
thead .selectColumn,
|
||||
thead .actionsColumn {
|
||||
background: #2a2d31;
|
||||
background: #2d3038;
|
||||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: #32363b;
|
||||
background: #363a42;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(124, 109, 216, 0.08);
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tr:nth-child(even):hover {
|
||||
background: rgba(124, 109, 216, 0.08);
|
||||
box-shadow: inset 3px 0 0 0 var(--color-secondary);
|
||||
}
|
||||
|
||||
.tr.selected {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
|
||||
* Cross-filtering is supported: changing a filter invalidates the cache,
|
||||
* so re-opening another column's dropdown re-fetches with current filters.
|
||||
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
|
||||
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker;
|
||||
* numeric columns render as operator + value (eq, gt, gte, lt, lte, between).
|
||||
*
|
||||
* BACKEND RESPONSE FORMAT (for refetch):
|
||||
* { items: T[], pagination: PaginationMetadata | null }
|
||||
|
|
@ -53,7 +54,7 @@
|
|||
*
|
||||
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
||||
*/
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorTable.module.css';
|
||||
|
|
@ -70,11 +71,14 @@ import { FormGeneratorControls } from '../FormGeneratorControls';
|
|||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||
import {
|
||||
isDateTimeType,
|
||||
isCheckboxType
|
||||
isCheckboxType,
|
||||
isNumberType,
|
||||
} from '../../../utils/attributeTypeMapper';
|
||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||
import { FaFilter } from 'react-icons/fa';
|
||||
import api from '../../../api';
|
||||
import { PeriodPicker } from '../../PeriodPicker';
|
||||
import type { PeriodValue } from '../../PeriodPicker';
|
||||
|
||||
/** A filter value can be a plain string, null (for empty/missing), or a
|
||||
* {value, label} object returned by FK-aware filter-values endpoints. */
|
||||
|
|
@ -348,6 +352,188 @@ function FilterValuesList({
|
|||
);
|
||||
}
|
||||
|
||||
/** PowerOn audit / booking timestamps stored as float (unix) but ``frontend_type`` is ``timestamp``. */
|
||||
function _auditTimestampColumnKey(key: string): boolean {
|
||||
const k = key.toLowerCase();
|
||||
const exact = new Set([
|
||||
'syscreatedat', 'sysmodifiedat', 'createdat', 'updatedat', 'modifiedat',
|
||||
'deletedat', 'syncedat', 'bookingdate', 'chartcachedat',
|
||||
]);
|
||||
if (exact.has(k)) return true;
|
||||
if (k.endsWith('timestamp')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
type _NumericUiOp = 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between';
|
||||
|
||||
function _parseNumericFilter(activeFilter: any): { op: _NumericUiOp; single: string; from: string; to: string } {
|
||||
if (activeFilter === undefined || activeFilter === null || activeFilter === '') {
|
||||
return { op: 'eq', single: '', from: '', to: '' };
|
||||
}
|
||||
if (typeof activeFilter === 'string' || typeof activeFilter === 'number') {
|
||||
return { op: 'eq', single: String(activeFilter), from: '', to: '' };
|
||||
}
|
||||
if (typeof activeFilter === 'object' && activeFilter !== null && 'operator' in activeFilter) {
|
||||
const opRaw = activeFilter.operator;
|
||||
const val = activeFilter.value;
|
||||
if (opRaw === 'between' && val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
return {
|
||||
op: 'between',
|
||||
single: '',
|
||||
from: val.from != null ? String(val.from) : '',
|
||||
to: val.to != null ? String(val.to) : '',
|
||||
};
|
||||
}
|
||||
if (opRaw === 'equals' || opRaw === 'eq') {
|
||||
return { op: 'eq', single: val != null && val !== '' ? String(val) : '', from: '', to: '' };
|
||||
}
|
||||
if (opRaw === 'gt' || opRaw === 'gte' || opRaw === 'lt' || opRaw === 'lte') {
|
||||
return { op: opRaw, single: val != null && val !== '' ? String(val) : '', from: '', to: '' };
|
||||
}
|
||||
}
|
||||
return { op: 'eq', single: '', from: '', to: '' };
|
||||
}
|
||||
|
||||
/** Column filter UI for numeric types (operator + value / range). */
|
||||
function NumericFilterPanel({
|
||||
columnKey,
|
||||
activeFilter,
|
||||
onFilter,
|
||||
onClear,
|
||||
t,
|
||||
}: {
|
||||
columnKey: string;
|
||||
activeFilter: any;
|
||||
onFilter: (payload: { operator: string; value: any }, keepOpen?: boolean) => void;
|
||||
onClear: () => void;
|
||||
t: (key: string, params?: Record<string, string | number | boolean>) => string;
|
||||
}) {
|
||||
const parsed = _parseNumericFilter(activeFilter);
|
||||
const [op, setOp] = useState<_NumericUiOp>(parsed.op);
|
||||
const [single, setSingle] = useState(parsed.single);
|
||||
const [from, setFrom] = useState(parsed.from);
|
||||
const [to, setTo] = useState(parsed.to);
|
||||
|
||||
useEffect(() => {
|
||||
const p = _parseNumericFilter(activeFilter);
|
||||
setOp(p.op);
|
||||
setSingle(p.single);
|
||||
setFrom(p.from);
|
||||
setTo(p.to);
|
||||
}, [columnKey, activeFilter]);
|
||||
|
||||
const _apply = () => {
|
||||
if (op === 'between') {
|
||||
const f = from.trim();
|
||||
const t0 = to.trim();
|
||||
if (!f && !t0) {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
onFilter({ operator: 'between', value: { from: f, to: t0 } }, true);
|
||||
return;
|
||||
}
|
||||
const trimmed = single.trim().replace(/'/g, '').replace(/\s/g, '');
|
||||
if (trimmed === '') {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
const n = Number(trimmed);
|
||||
if (Number.isNaN(n)) {
|
||||
return;
|
||||
}
|
||||
const apiOp = op === 'eq' ? 'equals' : op;
|
||||
onFilter({ operator: apiOp, value: n }, true);
|
||||
};
|
||||
|
||||
const hasActive = activeFilter !== undefined && activeFilter !== null && activeFilter !== '';
|
||||
|
||||
return (
|
||||
<div className={styles.filterNumericPanel}>
|
||||
<div
|
||||
className={`${styles.filterOption} ${!hasActive ? styles.filterOptionSelected : ''}`}
|
||||
onClick={onClear}
|
||||
>
|
||||
({t('Alle')})
|
||||
</div>
|
||||
<div className={styles.filterNumericRow}>
|
||||
<label className={styles.filterNumericLabel} htmlFor={`fg-num-op-${columnKey}`}>
|
||||
{t('Vergleich')}
|
||||
</label>
|
||||
<select
|
||||
id={`fg-num-op-${columnKey}`}
|
||||
className={styles.filterOperatorSelect}
|
||||
value={op}
|
||||
onChange={(e) => setOp(e.target.value as _NumericUiOp)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="eq">=</option>
|
||||
<option value="gt">></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>>({
|
||||
data,
|
||||
columns: providedColumns,
|
||||
|
|
@ -545,6 +731,41 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!openFilterColumn) return;
|
||||
const dd = filterDropdownRef.current;
|
||||
if (!dd) return;
|
||||
const positionDropdown = () => {
|
||||
const th = dd.closest('th');
|
||||
if (!th) return;
|
||||
const r = th.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const maxW = 320;
|
||||
const w = Math.min(Math.max(dd.offsetWidth || maxW, 200), maxW, window.innerWidth - 2 * margin);
|
||||
let left = r.left;
|
||||
if (left + w > window.innerWidth - margin) {
|
||||
left = window.innerWidth - margin - w;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
const approxH = dd.offsetHeight || 280;
|
||||
let top = r.bottom + 4;
|
||||
if (top + approxH > window.innerHeight - margin) {
|
||||
top = Math.max(margin, r.top - 4 - approxH);
|
||||
}
|
||||
dd.style.position = 'fixed';
|
||||
dd.style.left = `${left}px`;
|
||||
dd.style.top = `${top}px`;
|
||||
dd.style.right = 'auto';
|
||||
dd.style.bottom = 'auto';
|
||||
dd.style.width = `${w}px`;
|
||||
dd.style.maxWidth = `${maxW}px`;
|
||||
dd.style.zIndex = '2000';
|
||||
};
|
||||
positionDropdown();
|
||||
const id = requestAnimationFrame(() => positionDropdown());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [openFilterColumn]);
|
||||
|
||||
// Grouping: Track expanded groups
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
|
||||
const [groupsInitialized, setGroupsInitialized] = useState(false);
|
||||
|
|
@ -945,6 +1166,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
// Skip if column has static filterOptions (enum) – those are used directly
|
||||
if (column?.filterOptions && column.filterOptions.length > 0) return;
|
||||
|
||||
// Boolean / date / number columns use dedicated filter UIs — no distinct-value fetch
|
||||
const colT = column?.type as AttributeType | undefined;
|
||||
const auditTs = column?.key ? _auditTimestampColumnKey(column.key) : false;
|
||||
const treatAsDate = !!colT && (
|
||||
isDateTimeType(colT)
|
||||
|| (isNumberType(colT) && auditTs)
|
||||
);
|
||||
if (column?.type && (
|
||||
isCheckboxType(colT as AttributeType)
|
||||
|| treatAsDate
|
||||
|| (isNumberType(colT as AttributeType) && !auditTs)
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// displayField + local full dataset: filter values are derived from `data` (see getUniqueValuesForColumn)
|
||||
if (column?.displayField && !supportsBackendPagination) return;
|
||||
|
||||
|
|
@ -1619,6 +1855,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return false;
|
||||
};
|
||||
|
||||
const _columnAlignStyle = (column: ColumnConfig): React.CSSProperties => {
|
||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
||||
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
|
||||
if (formatAlign === 'R') return { textAlign: 'right' };
|
||||
if (formatAlign === 'M') return { textAlign: 'center' };
|
||||
if (formatAlign === 'L') return { textAlign: 'left' };
|
||||
return isNumeric ? { textAlign: 'right' } : {};
|
||||
};
|
||||
|
||||
// Format cell value
|
||||
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
||||
// Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs)
|
||||
|
|
@ -1980,7 +2225,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
)}
|
||||
</th>
|
||||
)}
|
||||
{detectedColumns.map(column => (
|
||||
{detectedColumns.map(column => {
|
||||
const colAlign = _columnAlignStyle(column);
|
||||
const headerJustify = colAlign.textAlign === 'right' ? 'flex-end'
|
||||
: colAlign.textAlign === 'center' ? 'center' : 'flex-start';
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${styles.th} ${sortable && column.sortable ? styles.sortable : ''}`}
|
||||
|
|
@ -1988,10 +2237,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
width: columnWidths[column.key] || column.width || 150,
|
||||
minWidth: columnWidths[column.key] || column.width || 150,
|
||||
maxWidth: columnWidths[column.key] || column.width || 150,
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
...colAlign,
|
||||
}}
|
||||
>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.headerContent} style={{ justifyContent: headerJustify }}>
|
||||
{/* Filter icon */}
|
||||
{filterable && column.filterable !== false && (
|
||||
<button
|
||||
|
|
@ -2034,6 +2284,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
<span
|
||||
className={styles.columnLabel}
|
||||
onClick={() => column.sortable && handleSort(column.key)}
|
||||
title={column.label}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
|
|
@ -2058,11 +2309,77 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const colType = column.type || 'text';
|
||||
const auditTs = _auditTimestampColumnKey(column.key);
|
||||
const isDateCol = isDateTimeType(colType as AttributeType)
|
||||
|| (isNumberType(colType as AttributeType) && auditTs);
|
||||
|
||||
if (isDateCol) {
|
||||
const filterVal = filters[column.key];
|
||||
const periodVal: PeriodValue | null = (() => {
|
||||
if (!filterVal || typeof filterVal !== 'object') return null;
|
||||
const v = (filterVal as any).value;
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const { from, to } = v as { from?: string; to?: string };
|
||||
if (!from && !to) return null;
|
||||
const storedPresetKind = (filterVal as any).presetKind;
|
||||
let preset: PeriodValue['preset'];
|
||||
if (storedPresetKind === 'lastN' || storedPresetKind === 'nextN') {
|
||||
const amount = (filterVal as any).presetAmount ?? 7;
|
||||
const unit = (filterVal as any).presetUnit ?? 'day';
|
||||
preset = { kind: storedPresetKind, amount, unit };
|
||||
} else if (storedPresetKind) {
|
||||
preset = { kind: storedPresetKind } as PeriodValue['preset'];
|
||||
} else {
|
||||
preset = { kind: 'custom' as const };
|
||||
}
|
||||
return { preset, fromDate: from || '', toDate: to || '' };
|
||||
})();
|
||||
return (
|
||||
<div className={styles.filterDatePickerWrap}>
|
||||
<PeriodPicker
|
||||
value={periodVal}
|
||||
onChange={(next: PeriodValue) => {
|
||||
if (next.preset.kind === 'allTime') {
|
||||
clearFilter(column.key);
|
||||
} else {
|
||||
const filterPayload: any = {
|
||||
operator: 'between',
|
||||
value: { from: next.fromDate, to: next.toDate },
|
||||
presetKind: next.preset.kind,
|
||||
};
|
||||
if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') {
|
||||
filterPayload.presetAmount = (next.preset as any).amount;
|
||||
filterPayload.presetUnit = (next.preset as any).unit;
|
||||
}
|
||||
handleFilter(column.key, filterPayload, true);
|
||||
}
|
||||
}}
|
||||
direction="past"
|
||||
enabledPresets={[
|
||||
'allTime', 'ytd', 'lastYear', 'last12Months',
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'lastN', 'custom',
|
||||
]}
|
||||
placeholder={t('Zeitraum wählen')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
<div className={styles.filterDropdownOptions}>
|
||||
{(() => {
|
||||
const colType = column.type || 'text';
|
||||
const isBool = isCheckboxType(colType as AttributeType);
|
||||
const isDate = isDateTimeType(colType as AttributeType);
|
||||
const auditTs = _auditTimestampColumnKey(column.key);
|
||||
const isDateCol = isDateTimeType(colType as AttributeType)
|
||||
|| (isNumberType(colType as AttributeType) && auditTs);
|
||||
const isNum = isNumberType(colType as AttributeType) && !auditTs;
|
||||
|
||||
if (isDateCol) return null;
|
||||
|
||||
if (isBool) {
|
||||
const currentVal = filters[column.key];
|
||||
|
|
@ -2090,51 +2407,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
);
|
||||
}
|
||||
|
||||
if (isDate) {
|
||||
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
|
||||
if (isNum) {
|
||||
return (
|
||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div
|
||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('Alle')})
|
||||
</div>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
{t('Von')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rangeVal.from || ''}
|
||||
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||
onChange={(e) => {
|
||||
const from = e.target.value;
|
||||
const to = rangeVal.to || '';
|
||||
if (!from && !to) {
|
||||
clearFilter(column.key);
|
||||
} else {
|
||||
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
|
||||
}
|
||||
}}
|
||||
<NumericFilterPanel
|
||||
columnKey={column.key}
|
||||
activeFilter={filters[column.key]}
|
||||
onFilter={(payload, keepOpen = true) => handleFilter(column.key, payload, keepOpen)}
|
||||
onClear={() => clearFilter(column.key)}
|
||||
t={t}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -2362,15 +2644,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const cellValue = row[column.key];
|
||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
||||
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
|
||||
const alignStyle: React.CSSProperties = formatAlign === 'R'
|
||||
? { textAlign: 'right' }
|
||||
: formatAlign === 'M'
|
||||
? { textAlign: 'center' }
|
||||
: formatAlign === 'L'
|
||||
? { textAlign: 'left' }
|
||||
: isNumeric ? { textAlign: 'right' } : {};
|
||||
const alignStyle = _columnAlignStyle(column);
|
||||
return (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>
|
||||
|
|
@ -2486,17 +2760,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const cellValue = row[column.key];
|
||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
||||
// ``frontendFormat`` may carry an explicit alignment prefix
|
||||
// ("L:", "M:", "R:") that overrides the numeric default.
|
||||
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
|
||||
const alignStyle: React.CSSProperties = formatAlign === 'R'
|
||||
? { textAlign: 'right' }
|
||||
: formatAlign === 'M'
|
||||
? { textAlign: 'center' }
|
||||
: formatAlign === 'L'
|
||||
? { textAlign: 'left' }
|
||||
: isNumeric ? { textAlign: 'right' } : {};
|
||||
const alignStyle = _columnAlignStyle(column);
|
||||
return (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* actual commit to the parent via `onApply` / `onCancel`.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
||||
import {
|
||||
|
|
@ -200,6 +200,36 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
|||
return () => window.removeEventListener('keydown', _onKey);
|
||||
}, [draft, onApply, onCancel]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
if (!pop) return;
|
||||
const _clamp = () => {
|
||||
const parent = pop.parentElement;
|
||||
if (!parent) return;
|
||||
const pRect = parent.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const popW = pop.offsetWidth || 720;
|
||||
const popH = pop.offsetHeight || 400;
|
||||
let left = pRect.left;
|
||||
let top = pRect.bottom + 6;
|
||||
if (left + popW > window.innerWidth - margin) {
|
||||
left = window.innerWidth - margin - popW;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
if (top + popH > window.innerHeight - margin) {
|
||||
top = Math.max(margin, pRect.top - 6 - popH);
|
||||
}
|
||||
pop.style.position = 'fixed';
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.right = 'auto';
|
||||
pop.style.zIndex = '2001';
|
||||
};
|
||||
_clamp();
|
||||
const id = requestAnimationFrame(() => _clamp());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={popRef} className={styles.popover}>
|
||||
<div className={styles.body}>
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export interface Invitation {
|
|||
roleIds: string[];
|
||||
targetUsername: string;
|
||||
email?: string;
|
||||
createdBy: string;
|
||||
createdAt: number;
|
||||
sysCreatedBy: string;
|
||||
sysCreatedAt: number;
|
||||
expiresAt: number;
|
||||
usedBy?: string;
|
||||
usedAt?: number;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||
import { validateMandateName } from '../utils/mandateNameUtils';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||
|
||||
// Re-export types
|
||||
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
||||
|
|
@ -153,11 +155,11 @@ export function useAdminMandates() {
|
|||
return await fetchMandateByIdApi(request, mandateId);
|
||||
}, [request]);
|
||||
|
||||
// Generate columns from attributes (displayField = backend {field}Label for FK columns)
|
||||
const columns = attributes.map(attr => ({
|
||||
// Generate columns from attributes (types merged via resolveColumnTypes)
|
||||
const columns: ColumnConfig[] = useMemo(() => {
|
||||
const raw = attributes.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -166,6 +168,8 @@ export function useAdminMandates() {
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes);
|
||||
}, [attributes]);
|
||||
|
||||
// Create mandate
|
||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt';
|
|||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { formatUnixTimestamp } from '../utils/time';
|
||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||
import { fetchAttributes } from '../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import api from '../api';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
||||
|
|
@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
|||
|
||||
const _DashboardTab: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { showError } = useToast();
|
||||
|
||||
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
||||
|
|
@ -431,6 +435,13 @@ const _DashboardTab: React.FC = () => {
|
|||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'AutoRun')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => {});
|
||||
}, [request]);
|
||||
|
||||
const _loadMetrics = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -529,11 +540,10 @@ const _DashboardTab: React.FC = () => {
|
|||
stopped: t('Gestoppt'),
|
||||
}), [t]);
|
||||
|
||||
const _runColumns: ColumnConfig[] = useMemo(() => [
|
||||
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'workflowLabel',
|
||||
label: t('Workflow'),
|
||||
type: 'string',
|
||||
width: 200,
|
||||
sortable: true,
|
||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||
|
|
@ -541,7 +551,6 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -550,7 +559,6 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'featureInstanceId',
|
||||
label: t('Instanz'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -559,7 +567,6 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
type: 'string',
|
||||
width: 110,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -574,7 +581,6 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Gestartet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
|
|
@ -582,13 +588,17 @@ const _DashboardTab: React.FC = () => {
|
|||
{
|
||||
key: 'sysModifiedAt',
|
||||
label: t('Beendet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
], [t, _STATUS_LABELS]);
|
||||
|
||||
const _runColumns = useMemo(
|
||||
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
||||
[_rawRunColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _hookData = useMemo(() => ({
|
||||
refetch: _loadRuns,
|
||||
pagination: paginationMeta,
|
||||
|
|
@ -711,6 +721,13 @@ const _WorkflowsTab: React.FC = () => {
|
|||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Automation2Workflow')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => {});
|
||||
}, [request]);
|
||||
|
||||
const _load = useCallback(async (paginationParams?: any) => {
|
||||
if (paginationParams !== undefined) {
|
||||
|
|
@ -883,12 +900,11 @@ const _WorkflowsTab: React.FC = () => {
|
|||
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
||||
}, []);
|
||||
|
||||
const _columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
||||
{
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -897,7 +913,6 @@ const _WorkflowsTab: React.FC = () => {
|
|||
{
|
||||
key: 'featureInstanceId',
|
||||
label: t('Instanz'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -906,7 +921,6 @@ const _WorkflowsTab: React.FC = () => {
|
|||
{
|
||||
key: 'active',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
|
|
@ -914,13 +928,11 @@ const _WorkflowsTab: React.FC = () => {
|
|||
{
|
||||
key: 'isRunning',
|
||||
label: t('Läuft'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
|
|
@ -928,19 +940,22 @@ const _WorkflowsTab: React.FC = () => {
|
|||
{
|
||||
key: 'lastStartedAt',
|
||||
label: t('Zuletzt gestartet'),
|
||||
type: 'number',
|
||||
width: 160,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'runCount',
|
||||
label: t('Läufe'),
|
||||
type: 'number',
|
||||
width: 80,
|
||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const _columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _hookData = useMemo(() => ({
|
||||
refetch: _load,
|
||||
handleDelete: (id: string) => _handleDelete(id),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import {
|
|||
} from 'recharts';
|
||||
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
||||
import api from '../api';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { fetchAttributes } from '../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
|
|
@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100;
|
|||
|
||||
export const ComplianceAuditPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
|
||||
const { fetchMandates } = useUserMandates();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'AiAuditLogEntry').then(setAiAuditAttrs).catch(() => setAiAuditAttrs([]));
|
||||
fetchAttributes(request, 'AuditLogEntry').then(setAuditLogAttrs).catch(() => setAuditLogAttrs([]));
|
||||
fetchAttributes(request, 'DataNeutralizerAttributesView').then(setNeutAttrs).catch(() => setNeutAttrs([]));
|
||||
}, [request]);
|
||||
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||
|
|
@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
|
||||
// ── Column definitions ──
|
||||
|
||||
const aiLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||
key: 'instanceLabel',
|
||||
label: t('Feature-Instanz'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (val: any, row: any) => val || row?.featureCode || '–',
|
||||
},
|
||||
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'aiModel', label: t('AI-Modell'), sortable: true, filterable: true, width: 160 },
|
||||
{
|
||||
key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||
key: 'aiProvider',
|
||||
label: t('Provider / Typ'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => {
|
||||
const provider = val || '–';
|
||||
const op = row?.operationType;
|
||||
|
|
@ -453,63 +479,110 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
},
|
||||
},
|
||||
{
|
||||
key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110,
|
||||
formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–',
|
||||
key: 'priceCHF',
|
||||
label: t('Kosten (CHF)'),
|
||||
sortable: true,
|
||||
width: 110,
|
||||
formatter: (val: any) => (val != null ? Number(val).toFixed(4) : '–'),
|
||||
},
|
||||
{
|
||||
key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100,
|
||||
formatter: (val: any) => val ? '✓' : '–',
|
||||
key: 'neutralizationActive',
|
||||
label: t('Neutralisierung'),
|
||||
sortable: true,
|
||||
width: 100,
|
||||
formatter: (val: any) => (val ? '✓' : '–'),
|
||||
},
|
||||
{
|
||||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80,
|
||||
formatter: (val: any) => val ? t('OK') : t('Fehler'),
|
||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||||
key: 'success',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 80,
|
||||
formatter: (val: any) => (val ? t('OK') : t('Fehler')),
|
||||
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const auditLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
const aiLogColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawAiLogColumns, aiAuditAttrs),
|
||||
[_rawAiLogColumns, aiAuditAttrs],
|
||||
);
|
||||
|
||||
const _rawAuditLogColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110,
|
||||
key: 'category',
|
||||
label: t('Kategorie'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 110,
|
||||
cellClassName: (val: any) => {
|
||||
const color = _CATEGORY_COLORS[val as string];
|
||||
return color ? styles[`cat_${val}`] || '' : '';
|
||||
},
|
||||
formatter: (val: any) => val || '–',
|
||||
},
|
||||
{ key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 },
|
||||
{ key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 },
|
||||
{ key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'details', label: t('Details'), searchable: true, width: 250 },
|
||||
{
|
||||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70,
|
||||
formatter: (val: any) => val ? '✓' : '✗',
|
||||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||||
key: 'success',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
width: 70,
|
||||
formatter: (val: any) => (val ? '✓' : '✗'),
|
||||
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||
},
|
||||
{ key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 },
|
||||
{ key: 'ipAddress', label: t('IP'), width: 120 },
|
||||
], [t]);
|
||||
|
||||
const neutColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
|
||||
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
|
||||
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
const auditLogColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs),
|
||||
[_rawAuditLogColumns, auditLogAttrs],
|
||||
);
|
||||
|
||||
const _rawNeutColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'placeholder', label: t('Platzhalter'), sortable: true, searchable: true, width: 220 },
|
||||
{ key: 'originalText', label: t('Originaltext'), sortable: true, searchable: true, width: 240 },
|
||||
{ key: 'patternType', label: t('Kategorie'), sortable: true, filterable: true, width: 120 },
|
||||
{
|
||||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
|
||||
key: 'username',
|
||||
label: t('Benutzer'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
|
||||
key: 'instanceLabel',
|
||||
label: t('Feature-Instanz'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? `NA(${row.featureInstanceId})` : '–'),
|
||||
},
|
||||
{
|
||||
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
|
||||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
||||
key: 'fileId',
|
||||
label: t('Datei'),
|
||||
sortable: true,
|
||||
width: 140,
|
||||
formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const neutColumns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawNeutColumns, neutAttrs),
|
||||
[_rawNeutColumns, neutAttrs],
|
||||
);
|
||||
|
||||
// ── fetchFilterValues for autofilter dropdowns ──
|
||||
|
||||
const _makeFetchFilterValues = useCallback(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo
|
|||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { ChatbotConfigSection } from './ChatbotConfigSection';
|
||||
import { TextField } from '../../components/UiComponents/TextField';
|
||||
import styles from './Admin.module.css';
|
||||
|
|
@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
const { fetchMandates } = useUserMandates();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { loadFeatures } = useFeatureStore();
|
||||
const { request } = useApiRequest();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
|
|
@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
}
|
||||
}, [selectedMandateId, fetchInstances]);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{ key: 'label', label: t('Name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{ key: 'featureCode', label: t('Feature'), type: 'string' as const, sortable: true, filterable: true, width: 150,
|
||||
render: (value: string) => {
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{
|
||||
key: 'featureCode',
|
||||
label: t('Feature'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
formatter: (value: string) => {
|
||||
const feature = features.find(f => f.code === value);
|
||||
return feature ? (feature.label || value) : value;
|
||||
}
|
||||
const label = feature ? (feature.label || value) : value;
|
||||
return label;
|
||||
},
|
||||
{ key: 'enabled', label: t('Aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
|
||||
},
|
||||
{ key: 'enabled', label: t('Aktiv'), sortable: true, filterable: true, width: 80 },
|
||||
], [features, t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes from backend - merge with dynamic feature options
|
||||
// Exclude featureCode, config, and label since we handle them separately
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
|
|||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useFeatureStore } from '../../stores/featureStore';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
const { fetchMandates } = useUserMandates();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { loadFeatures } = useFeatureStore();
|
||||
const { request } = useApiRequest();
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Combined instance option type
|
||||
interface CombinedInstanceOption {
|
||||
|
|
@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
return selectedCombinedKey.split(':')[1] || '';
|
||||
}, [selectedCombinedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'FeatureAccessView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Load mandates and features on mount, then build combined options
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
|
|
@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, instanceUsers]);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
type: 'text' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
type: 'text' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string[]) => {
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
|
|
@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
|
|
@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
availableUsers.map(u => ({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules';
|
|||
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const [roleTableAttributes, setRoleTableAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// State
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
|
||||
|
|
@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Role')
|
||||
.then(setRoleTableAttributes)
|
||||
.catch(() => setRoleTableAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Load features on mount
|
||||
useEffect(() => {
|
||||
const loadFeatures = async () => {
|
||||
|
|
@ -130,29 +143,25 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
return String(value);
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'roleLabel',
|
||||
label: t('Rollen-Label'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: t('Beschreibung'),
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
width: 300,
|
||||
formatter: (value: string) => getTextValue(value)
|
||||
formatter: (value: string) => getTextValue(value),
|
||||
},
|
||||
{
|
||||
key: 'featureCode',
|
||||
label: t('Feature'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
|
|
@ -160,10 +169,15 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
|
||||
<FaCube style={{ marginRight: 4 }} /> {value}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, roleTableAttributes),
|
||||
[_rawColumns, roleTableAttributes],
|
||||
);
|
||||
|
||||
// Form attributes for create
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const fields: AttributeDefinition[] = [
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
|||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
invitations,
|
||||
loading,
|
||||
|
|
@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Invitation attributes from backend
|
||||
api.get('/api/attributes/Invitation').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'Invitation')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
||||
useEffect(() => {
|
||||
|
|
@ -84,12 +86,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'targetUsername',
|
||||
label: t('Benutzername'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -98,11 +98,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 180,
|
||||
render: (value: string, row: Invitation) => {
|
||||
formatter: (value: string, row: Invitation) => {
|
||||
const emailText = value || '-';
|
||||
const emailSent = (row as any).emailSent;
|
||||
return (
|
||||
|
|
@ -110,30 +109,28 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
{emailText} {emailSent && '✓'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'roleIds',
|
||||
label: t('Rollen'),
|
||||
type: 'string', // Array rendered as string
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 150,
|
||||
render: (value: string[]) => {
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.map(roleId => {
|
||||
return value.map((roleId) => {
|
||||
const role = roles.find(r => r.id === roleId);
|
||||
return role?.roleLabel || roleId;
|
||||
}).join(', ');
|
||||
}
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'expiresAt',
|
||||
label: t('Gültig bis'),
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number) => {
|
||||
formatter: (value: number) => {
|
||||
const text = formatDate(value);
|
||||
const isExpired = value < Date.now() / 1000;
|
||||
return (
|
||||
|
|
@ -141,29 +138,32 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
{text} {isExpired && '(abgelaufen)'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'currentUses',
|
||||
label: t('Verwendet'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
width: 100,
|
||||
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
|
||||
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number) => formatDate(value)
|
||||
formatter: (value: number) => formatDate(value),
|
||||
},
|
||||
], [roles, t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||||
|
||||
// Mandate-level roles (user, viewer, admin) - same as when adding mandate members
|
||||
const roleOptions = roles
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import api from '../../api';
|
|||
import axios from 'axios';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
|
|
@ -39,44 +43,6 @@ type ProgressInfo = {
|
|||
keysTranslated?: number;
|
||||
};
|
||||
|
||||
function _getColumns(t: (key: string) => string): ColumnConfig[] {
|
||||
return [
|
||||
{ key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
|
||||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
type: 'text',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (_val: any, row: any) => {
|
||||
const r = row as LangRow;
|
||||
if (r.updating) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird aktualisiert…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (r.status === 'generating') {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird erzeugt…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return r.status;
|
||||
},
|
||||
},
|
||||
{ key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 },
|
||||
{ key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 },
|
||||
{ key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 },
|
||||
];
|
||||
}
|
||||
|
||||
// ISO 639 catalog (codes + native labels + priority order) is provided by the
|
||||
// gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here --
|
||||
// any divergence between frontend and backend caused subtle bugs (e.g. user
|
||||
|
|
@ -278,6 +244,8 @@ const _ProgressOverlay: React.FC<{
|
|||
export const AdminLanguagesPage: React.FC = () => {
|
||||
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { request } = useApiRequest();
|
||||
const [langSetAttributes, setLangSetAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [rows, setRows] = useState<LangRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -288,6 +256,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
const busyRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'UiLanguageSetView')
|
||||
.then(setLangSetAttributes)
|
||||
.catch(() => setLangSetAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
|
|
@ -393,6 +367,44 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
});
|
||||
}, [rows, search]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const raw: ColumnConfig[] = [
|
||||
{ key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'label', label: t('Bezeichnung'), sortable: true, filterable: true, width: 200 },
|
||||
{
|
||||
key: 'status',
|
||||
label: t('Status'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 160,
|
||||
formatter: (_val: any, row: any) => {
|
||||
const r = row as LangRow;
|
||||
if (r.updating) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird aktualisiert…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (r.status === 'generating') {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
||||
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
||||
{t('wird erzeugt…')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return r.status;
|
||||
},
|
||||
},
|
||||
{ key: 'uiCount', label: t('UI'), sortable: true, width: 80 },
|
||||
{ key: 'gatewayCount', label: t('API'), sortable: true, width: 80 },
|
||||
{ key: 'entriesCount', label: t('Gesamt'), sortable: true, width: 80 },
|
||||
];
|
||||
return resolveColumnTypes(raw, langSetAttributes);
|
||||
}, [t, langSetAttributes]);
|
||||
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||
|
||||
const addChoices = useMemo(() => {
|
||||
|
|
@ -869,7 +881,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<FormGeneratorTable
|
||||
data={displayRows}
|
||||
columns={_getColumns(t)}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
selectable={false}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
|||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -31,6 +34,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
const { t, currentLanguage } = useLanguage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
|
|
@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Role attributes from backend
|
||||
api.get('/api/attributes/Role').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'Role')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load roles when mandate or scopeFilter changes
|
||||
useEffect(() => {
|
||||
|
|
@ -102,30 +104,26 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
return String(desc);
|
||||
};
|
||||
|
||||
// Table columns - scopeType is now a backend-computed field
|
||||
const columns = useMemo(() => [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'roleLabel',
|
||||
label: t('Bezeichnung'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: t('Beschreibung'),
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 250,
|
||||
formatter: (value: string) => getDescriptionText(value)
|
||||
formatter: (value: string) => getDescriptionText(value),
|
||||
},
|
||||
{
|
||||
key: 'scopeType',
|
||||
label: t('Geltungsbereich'),
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
|
|
@ -149,10 +147,15 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes from backend - for create form
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
|||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
|
|
@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch UserMandate attributes from backend (for table columns)
|
||||
api.get('/api/attributes/UserMandate').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
fetchAttributes(request, 'UserMandateView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load users when mandate changes
|
||||
useEffect(() => {
|
||||
|
|
@ -97,13 +99,10 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, users]);
|
||||
|
||||
// Table columns - based on MandateUserInfo response structure
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -112,7 +111,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -121,7 +119,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -130,12 +127,11 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
type: 'text' as any,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string[]) => {
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
|
|
@ -143,14 +139,17 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import styles from './Admin.module.css';
|
|||
import { getUserDataCache } from '../../utils/userCache';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
||||
|
||||
|
|
@ -57,12 +58,11 @@ export const AdminUsersPage: React.FC = () => {
|
|||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes; types from backend via resolveColumnTypes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -71,6 +71,7 @@ export const AdminUsersPage: React.FC = () => {
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
export const ConnectionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -54,13 +55,12 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const columns = useMemo(() => {
|
||||
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
|
||||
|
||||
return (attributes || [])
|
||||
const raw = (attributes || [])
|
||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||
.map(attr => {
|
||||
const col: any = {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -71,13 +71,9 @@ export const ConnectionsPage: React.FC = () => {
|
|||
frontendFormat: (attr as any).frontendFormat,
|
||||
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||
};
|
||||
|
||||
if (attr.name === 'userId') {
|
||||
col.label = t('Benutzer');
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
interface UserFile {
|
||||
id: string;
|
||||
|
|
@ -203,7 +204,6 @@ export const FilesPage: React.FC = () => {
|
|||
.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -217,7 +217,6 @@ export const FilesPage: React.FC = () => {
|
|||
cols.push({
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -226,7 +225,7 @@ export const FilesPage: React.FC = () => {
|
|||
maxWidth: 250,
|
||||
displayField: 'sysCreatedByLabel',
|
||||
} as any);
|
||||
return cols;
|
||||
return resolveColumnTypes(cols, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa';
|
|||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
|
|
@ -76,7 +77,6 @@ export const PromptsPage: React.FC = () => {
|
|||
.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -92,7 +92,6 @@ export const PromptsPage: React.FC = () => {
|
|||
cols.push({
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
|
|
@ -104,7 +103,7 @@ export const PromptsPage: React.FC = () => {
|
|||
frontendFormatLabels: undefined,
|
||||
});
|
||||
|
||||
return cols;
|
||||
return resolveColumnTypes(cols, attributes || []);
|
||||
}, [attributes, t]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import api from '../../api';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
|
|
@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
|||
|
||||
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||
|
||||
function _getColumns(t: (key: string) => string): ColumnConfig[] {
|
||||
return [
|
||||
{ key: 'mandateName', label: t('Mandant'), type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'planTitle', label: t('Plan'), type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 110 },
|
||||
{ key: 'recurring', label: t('Wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'activeUsers', label: t('Benutzer'), type: 'number', sortable: true, width: 70 },
|
||||
{ key: 'activeInstances', label: t('Module'), type: 'number', sortable: true, width: 90 },
|
||||
{ key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), type: 'number', sortable: true, width: 140 },
|
||||
{ key: 'startedAt', label: t('Gestartet'), type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'currentPeriodEnd', label: t('Periodenende'), type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), type: 'number', sortable: true, width: 100 },
|
||||
{ key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), type: 'number', sortable: true, width: 110 },
|
||||
];
|
||||
}
|
||||
|
||||
const AdminSubscriptionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'MandateSubscriptionView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
|
||||
{ key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
|
||||
{ key: 'activeInstances', label: t('Module'), sortable: true, width: 90 },
|
||||
{ key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), sortable: true, width: 140 },
|
||||
{ key: 'startedAt', label: t('Gestartet'), sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'currentPeriodEnd', label: t('Periodenende'), sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), sortable: true, width: 100 },
|
||||
{ key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), sortable: true, width: 110 },
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const _handleForceCancel = useCallback(async (row: any) => {
|
||||
const ok = await confirm(
|
||||
t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }),
|
||||
|
|
@ -44,7 +59,7 @@ const AdminSubscriptionsPage: React.FC = () => {
|
|||
} catch (err) {
|
||||
console.error('Force cancel failed:', err);
|
||||
}
|
||||
}, [confirm, refetch]);
|
||||
}, [confirm, refetch, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||
|
|
@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
|
|||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<FormGeneratorTable
|
||||
data={subscriptions}
|
||||
columns={_getColumns(t)}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/subscription/admin/all"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
|
|||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||
import { UserTransaction } from '../../api/billingApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
|
|
@ -252,6 +256,8 @@ function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'ba
|
|||
|
||||
export const BillingDataView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [billingTxnAttributes, setBillingTxnAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
|
@ -338,6 +344,12 @@ export const BillingDataView: React.FC = () => {
|
|||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'BillingTransactionView')
|
||||
.then(setBillingTxnAttributes)
|
||||
.catch(() => setBillingTxnAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
// Unified scope params -- single source of truth for all tab API calls
|
||||
// "nur meine Daten" is an additional filter on top of the dropdown scope
|
||||
const _scopeParams = useMemo((): Record<string, string> => {
|
||||
|
|
@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => {
|
|||
fetchFilterValues: _fetchTransactionFilterValues,
|
||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
||||
|
||||
// Table column definitions
|
||||
const columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
|
||||
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: t('Benutzer'), sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: t('Typ'), sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: t('Beschreibung'), searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 },
|
||||
], [t]);
|
||||
|
||||
const columns: ColumnConfig[] = useMemo(
|
||||
() => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes),
|
||||
[_rawTransactionColumns, billingTxnAttributes],
|
||||
);
|
||||
|
||||
const totalBalance = useMemo(() => {
|
||||
const filtered = selectedScope === 'personal' || selectedScope === 'all'
|
||||
? balances
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
|||
<tbody>
|
||||
{transactions.map((txn) => (
|
||||
<tr key={txn.id}>
|
||||
<td>{formatDate(txn.createdAt)}</td>
|
||||
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||
<td>{txn.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
|||
|
||||
return (
|
||||
<tr>
|
||||
<td>{formatDate(transaction.createdAt)}</td>
|
||||
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
||||
<td>{transaction.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
|
|||
<tbody>
|
||||
{filteredTransactions.map((txn) => (
|
||||
<tr key={txn.id}>
|
||||
<td>{formatDate(txn.createdAt)}</td>
|
||||
<td>{formatDate(txn.sysCreatedAt)}</td>
|
||||
<td>{txn.mandateName || '-'}</td>
|
||||
<td>{txn.userName || '-'}</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import {
|
|||
type AutoWorkflowTemplate,
|
||||
type AutoTemplateScope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { fetchAttributes } from '../../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from '../../../pages/admin/Admin.module.css';
|
||||
|
|
@ -68,6 +71,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'AutoWorkflow')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => {});
|
||||
}, [request]);
|
||||
|
||||
const load = useCallback(async (paginationParams?: any) => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -173,20 +183,18 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
[mandateId, instanceId, navigate]
|
||||
);
|
||||
|
||||
const columns: ColumnConfig[] = useMemo(
|
||||
const _rawColumns: ColumnConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'label', label: t('Vorlage'), type: 'string', width: 220, sortable: true },
|
||||
{ key: 'label', label: t('Vorlage'), width: 220, sortable: true },
|
||||
{
|
||||
key: 'templateScope',
|
||||
label: t('Bereich'),
|
||||
type: 'string',
|
||||
width: 100,
|
||||
formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'sharedReadOnly',
|
||||
label: t('Freigegeben'),
|
||||
type: 'boolean',
|
||||
width: 100,
|
||||
formatter: (v: boolean) =>
|
||||
v ? (
|
||||
|
|
@ -198,14 +206,12 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
{
|
||||
key: 'sysCreatedBy',
|
||||
label: t('Erstellt von'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
displayField: 'sysCreatedByLabel',
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
|
|
@ -213,6 +219,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
[t, scopeLabels],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
|
|
@ -26,6 +26,9 @@ import {
|
|||
type Automation2Workflow,
|
||||
type WorkflowFileEnvelope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { fetchAttributes } from '../../../api/attributesApi';
|
||||
import type { AttributeDefinition } from '../../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from '../../../pages/admin/Admin.module.css';
|
||||
|
|
@ -64,6 +67,13 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'Automation2Workflow')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => {});
|
||||
}, [request]);
|
||||
|
||||
const load = useCallback(async (paginationParams?: any) => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -251,12 +261,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
[instanceId, request, showSuccess, showError, load, t],
|
||||
);
|
||||
|
||||
const columns: ColumnConfig[] = [
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), width: 200, sortable: true },
|
||||
{
|
||||
key: 'active',
|
||||
label: t('Aktiv (Spalte)'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value !== false ? (
|
||||
|
|
@ -268,7 +277,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
{
|
||||
key: 'isRunning',
|
||||
label: t('läuft'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value ? (
|
||||
|
|
@ -280,7 +288,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
{
|
||||
key: 'stuckAtNodeLabel',
|
||||
label: t('steht bei'),
|
||||
type: 'string',
|
||||
width: 160,
|
||||
formatter: (value: string, row: Automation2Workflow) =>
|
||||
row.isRunning && (value || row.stuckAtNodeId)
|
||||
|
|
@ -290,25 +297,27 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
{
|
||||
key: 'createdAt',
|
||||
label: t('Erstellt'),
|
||||
type: 'number',
|
||||
width: 140,
|
||||
formatter: (v: number) => formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'lastStartedAt',
|
||||
label: t('zuletzt gestartet'),
|
||||
type: 'number',
|
||||
width: 160,
|
||||
formatter: (v: number) => formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'runCount',
|
||||
label: t('Läufe'),
|
||||
type: 'number',
|
||||
width: 80,
|
||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||
},
|
||||
];
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const hookData = {
|
||||
refetch: load,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const RealEstateParcelsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -54,10 +55,9 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -66,6 +66,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const RealEstateProjectsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -52,10 +53,9 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
const raw = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -64,6 +64,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: (attr as any).displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import api from '../../../api';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteeDocumentsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -70,7 +71,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
const allCols = (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -88,7 +88,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
for (const col of allCols) {
|
||||
if (byKey.has(col.key)) ordered.push(col);
|
||||
}
|
||||
return ordered;
|
||||
return resolveColumnTypes(ordered, attributes || []);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { FaSync } from 'react-icons/fa';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteePositionDocumentsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -56,12 +57,11 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
// Exclude system fields from table columns
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||
|
||||
return attributes
|
||||
const raw = attributes
|
||||
.filter((attr: any) => !excludedFields.includes(attr.name))
|
||||
.map((attr: any) => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -70,6 +70,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
displayField: attr.displayField,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes);
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions (general level)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { formatAmount, formatPercent } from '../../../utils/formatAmount';
|
|||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||
|
||||
export const TrusteePositionsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -285,7 +286,6 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const col: ColumnConfig = {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -320,7 +320,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const col = byKey.get(key);
|
||||
if (col) ordered.push(col);
|
||||
}
|
||||
return ordered;
|
||||
return resolveColumnTypes(ordered, attributes || []);
|
||||
}, [attributes, belegeColumn, syncStatusColumn]);
|
||||
|
||||
// Check permissions
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGene
|
|||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
|
||||
import adminStyles from '../../../admin/Admin.module.css';
|
||||
import { resolveColumnTypes } from '../../../../utils/columnTypeResolver';
|
||||
|
||||
export interface TrusteeDataTabProps {
|
||||
/** Result of the entity hook factory call (see `useTrustee.ts`). */
|
||||
|
|
@ -117,12 +118,11 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
|||
|
||||
const columns = useMemo(() => {
|
||||
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
|
||||
return (attributes || [])
|
||||
const raw = (attributes || [])
|
||||
.filter((attr: any) => !hidden.has(attr.name))
|
||||
.map((attr: any) => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: (attr.type as any) || 'text',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
|
|
@ -133,6 +133,7 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
|||
frontendFormat: attr.frontendFormat,
|
||||
frontendFormatLabels: attr.frontendFormatLabels,
|
||||
}));
|
||||
return resolveColumnTypes(raw, attributes || []);
|
||||
}, [attributes, hiddenColumns]);
|
||||
|
||||
const formAttributes = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ export type AttributeType =
|
|||
| 'string'
|
||||
| 'enum'
|
||||
| 'slug'
|
||||
| 'readonly';
|
||||
| 'readonly'
|
||||
| 'object'
|
||||
| 'json';
|
||||
|
||||
export type InputComponentType =
|
||||
| 'text'
|
||||
|
|
@ -82,6 +84,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
return 'multiselect';
|
||||
|
||||
case 'integer':
|
||||
case 'int':
|
||||
case 'number':
|
||||
case 'float':
|
||||
return 'number';
|
||||
|
|
@ -114,6 +117,10 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
case 'readonly':
|
||||
return 'text'; // Default to text for readonly, but should be rendered as readonly
|
||||
|
||||
case 'object':
|
||||
case 'json':
|
||||
return 'textarea';
|
||||
|
||||
default:
|
||||
// Default fallback to text input
|
||||
return 'text';
|
||||
|
|
@ -124,7 +131,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
|||
* Determines if an attribute type should render as a textarea
|
||||
*/
|
||||
export function isTextareaType(attributeType: AttributeType): boolean {
|
||||
return attributeType === 'textarea';
|
||||
return attributeType === 'textarea' || attributeType === 'object' || attributeType === 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -166,7 +173,12 @@ export function isFileType(attributeType: AttributeType): boolean {
|
|||
* Determines if an attribute type should render as a number input
|
||||
*/
|
||||
export function isNumberType(attributeType: AttributeType): boolean {
|
||||
return attributeType === 'integer' || attributeType === 'number' || attributeType === 'float';
|
||||
return (
|
||||
attributeType === 'integer'
|
||||
|| attributeType === 'int'
|
||||
|| attributeType === 'number'
|
||||
|| attributeType === 'float'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,6 +188,25 @@ export function isDateTimeType(attributeType: AttributeType): boolean {
|
|||
return attributeType === 'timestamp' || attributeType === 'date' || attributeType === 'time';
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of AttributeType suitable for user-facing form fields (workflow start forms,
|
||||
* input forms, field builders). Components render labels via t(FORM_FIELD_TYPE_LABELS[ft]).
|
||||
*/
|
||||
export const FORM_FIELD_TYPES: AttributeType[] = [
|
||||
'text', 'textarea', 'number', 'email', 'date', 'boolean', 'select', 'checkbox',
|
||||
];
|
||||
|
||||
export const FORM_FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
textarea: 'Mehrzeilig',
|
||||
number: 'Zahl',
|
||||
email: 'E-Mail',
|
||||
date: 'Datum',
|
||||
boolean: 'Ja/Nein',
|
||||
select: 'Auswahl',
|
||||
checkbox: 'Kontrollkästchen',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the default value for an attribute type
|
||||
*/
|
||||
|
|
|
|||
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