diff --git a/src/api/attributesApi.ts b/src/api/attributesApi.ts index 1776fb9..b01f5b9 100644 --- a/src/api/attributesApi.ts +++ b/src/api/attributesApi.ts @@ -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; diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 930749d..cc05e44 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -29,7 +29,7 @@ export interface BillingTransaction { aicoreProvider?: string; aicoreModel?: string; createdByUserId?: string; - createdAt?: string; + sysCreatedAt?: string; mandateId?: string; mandateName?: string; userId?: string; diff --git a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx index 3bb88f5..fbb8dc3 100644 --- a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx +++ b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx @@ -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 = ({ params, - updateParam, - instanceId, - request, -}) => { +export const FormNodeConfig: React.FC = ({ params, updateParam }) => { const { t } = useLanguage(); const fields = (params.fields as FormField[]) ?? []; - const [connections, setConnections] = useState([]); - 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 = ({ params,
- {f.type === 'clickup_status' ? ( -
- {Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? ( -

- {t( - 'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).', - { count: String(f.clickupStatusOptions.length) } - )} -

- ) : ( -

- {t( - 'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.' - )} -

- )} -
- ) : null} - {f.type === 'clickup_tasks' ? ( -
- - - - { - const next = [...fields]; - next[i] = { ...next[i], clickupListId: e.target.value }; - updateParam('fields', next); - }} - style={{ width: '100%' }} - /> -

- {t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '} - {'{ add: [taskId], rem: [] }'}{' '} - {t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')} -

-
- ) : null} ))} + + + ); +} + export function FormGeneratorTable>({ data, columns: providedColumns, @@ -544,6 +730,41 @@ export function FormGeneratorTable>({ const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [openFilterColumn, setOpenFilterColumn] = useState(null); const filterDropdownRef = useRef(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>(() => new Set()); @@ -945,6 +1166,21 @@ export function FormGeneratorTable>({ // 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>({ 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>({ )} )} - {detectedColumns.map(column => ( + {detectedColumns.map(column => { + const colAlign = _columnAlignStyle(column); + const headerJustify = colAlign.textAlign === 'right' ? 'flex-end' + : colAlign.textAlign === 'center' ? 'center' : 'flex-start'; + return ( >({ 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, }} > -
+
{/* Filter icon */} {filterable && column.filterable !== false && ( )}
+ {(() => { + 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 ( +
+ { + 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')} + /> +
+ ); + } + + return null; + })()}
{(() => { 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>({ ); } - if (isDate) { - const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {}; + if (isNum) { return ( -
-
clearFilter(column.key)} - > - ({t('Alle')}) -
- - { - 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); - } - }} - /> - - { - 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); - } - }} - /> -
+ handleFilter(column.key, payload, keepOpen)} + onClear={() => clearFilter(column.key)} + t={t} + /> ); } @@ -2184,7 +2465,8 @@ export function FormGeneratorTable>({ /> )} - ))} + ); + })} @@ -2362,15 +2644,7 @@ export function FormGeneratorTable>({ const cellValue = row[column.key]; const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); - const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; - const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; - const alignStyle: React.CSSProperties = formatAlign === 'R' - ? { textAlign: 'right' } - : formatAlign === 'M' - ? { textAlign: 'center' } - : formatAlign === 'L' - ? { textAlign: 'left' } - : isNumeric ? { textAlign: 'right' } : {}; + const alignStyle = _columnAlignStyle(column); return ( @@ -2486,17 +2760,7 @@ export function FormGeneratorTable>({ const cellValue = row[column.key]; const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); - const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; - // ``frontendFormat`` may carry an explicit alignment prefix - // ("L:", "M:", "R:") that overrides the numeric default. - const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; - const alignStyle: React.CSSProperties = formatAlign === 'R' - ? { textAlign: 'right' } - : formatAlign === 'M' - ? { textAlign: 'center' } - : formatAlign === 'L' - ? { textAlign: 'left' } - : isNumeric ? { textAlign: 'right' } : {}; + const alignStyle = _columnAlignStyle(column); return ( diff --git a/src/components/PeriodPicker/PeriodPickerPopover.tsx b/src/components/PeriodPicker/PeriodPickerPopover.tsx index f21dd67..d1c5aec 100644 --- a/src/components/PeriodPicker/PeriodPickerPopover.tsx +++ b/src/components/PeriodPicker/PeriodPickerPopover.tsx @@ -5,7 +5,7 @@ * actual commit to the parent via `onApply` / `onCancel`. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLanguage } from '../../providers/language/LanguageContext'; import PeriodPickerCalendar from './PeriodPickerCalendar'; import { @@ -200,6 +200,36 @@ const PeriodPickerPopover: React.FC = (props) => { return () => window.removeEventListener('keydown', _onKey); }, [draft, onApply, onCancel]); + useLayoutEffect(() => { + const pop = popRef.current; + if (!pop) return; + const _clamp = () => { + const parent = pop.parentElement; + if (!parent) return; + const pRect = parent.getBoundingClientRect(); + const margin = 8; + const popW = pop.offsetWidth || 720; + const popH = pop.offsetHeight || 400; + let left = pRect.left; + let top = pRect.bottom + 6; + if (left + popW > window.innerWidth - margin) { + left = window.innerWidth - margin - popW; + } + if (left < margin) left = margin; + if (top + popH > window.innerHeight - margin) { + top = Math.max(margin, pRect.top - 6 - popH); + } + pop.style.position = 'fixed'; + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + pop.style.right = 'auto'; + pop.style.zIndex = '2001'; + }; + _clamp(); + const id = requestAnimationFrame(() => _clamp()); + return () => cancelAnimationFrame(id); + }, []); + return (
diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index caf9d41..61a6fd9 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -32,8 +32,8 @@ export interface Invitation { roleIds: string[]; targetUsername: string; email?: string; - createdBy: string; - createdAt: number; + sysCreatedBy: string; + sysCreatedAt: number; expiresAt: number; usedBy?: string; usedAt?: number; diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts index 6b56420..9444acd 100644 --- a/src/hooks/useMandates.ts +++ b/src/hooks/useMandates.ts @@ -24,6 +24,8 @@ import { import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm'; import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge'; import { validateMandateName } from '../utils/mandateNameUtils'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; +import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; // Re-export types export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams }; @@ -153,19 +155,21 @@ export function useAdminMandates() { return await fetchMandateByIdApi(request, mandateId); }, [request]); - // Generate columns from attributes (displayField = backend {field}Label for FK columns) - const columns = attributes.map(attr => ({ - key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, - sortable: attr.sortable !== false, - filterable: attr.filterable !== false, - searchable: attr.searchable !== false, - width: attr.width || 150, - minWidth: attr.minWidth || 100, - maxWidth: attr.maxWidth || 400, - displayField: (attr as any).displayField, - })); + // Generate columns from attributes (types merged via resolveColumnTypes) + const columns: ColumnConfig[] = useMemo(() => { + const raw = attributes.map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + displayField: (attr as any).displayField, + })); + return resolveColumnTypes(raw, attributes); + }, [attributes]); // Create mandate const handleCreate = useCallback(async (mandateData: Partial): Promise => { diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index d8c19b1..e0923f7 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt'; import { useApiRequest } from '../hooks/useApi'; import { formatUnixTimestamp } from '../utils/time'; import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi'; +import { fetchAttributes } from '../api/attributesApi'; +import type { AttributeDefinition } from '../api/attributesApi'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; import api from '../api'; import { useLanguage } from '../providers/language/LanguageContext'; import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; @@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => const _DashboardTab: React.FC = () => { const { t } = useLanguage(); + const { request } = useApiRequest(); const { showError } = useToast(); const [metrics, setMetrics] = useState(null); @@ -431,6 +435,13 @@ const _DashboardTab: React.FC = () => { const [paginationMeta, setPaginationMeta] = useState(null); const [tracingRun, setTracingRun] = useState(null); const lastPaginationParamsRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'AutoRun') + .then(setBackendAttributes) + .catch(() => {}); + }, [request]); const _loadMetrics = useCallback(async () => { try { @@ -529,11 +540,10 @@ const _DashboardTab: React.FC = () => { stopped: t('Gestoppt'), }), [t]); - const _runColumns: ColumnConfig[] = useMemo(() => [ + const _rawRunColumns: ColumnConfig[] = useMemo(() => [ { key: 'workflowLabel', label: t('Workflow'), - type: 'string', width: 200, sortable: true, formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'), @@ -541,7 +551,6 @@ const _DashboardTab: React.FC = () => { { key: 'mandateId', label: t('Mandant'), - type: 'string', width: 140, sortable: true, filterable: true, @@ -550,7 +559,6 @@ const _DashboardTab: React.FC = () => { { key: 'featureInstanceId', label: t('Instanz'), - type: 'string', width: 140, sortable: true, filterable: true, @@ -559,7 +567,6 @@ const _DashboardTab: React.FC = () => { { key: 'status', label: t('Status'), - type: 'string', width: 110, sortable: true, filterable: true, @@ -574,7 +581,6 @@ const _DashboardTab: React.FC = () => { { key: 'sysCreatedAt', label: t('Gestartet'), - type: 'number', width: 150, sortable: true, formatter: (v: number) => _formatTs(v), @@ -582,13 +588,17 @@ const _DashboardTab: React.FC = () => { { key: 'sysModifiedAt', label: t('Beendet'), - type: 'number', width: 150, sortable: true, formatter: (v: number) => _formatTs(v), }, ], [t, _STATUS_LABELS]); + const _runColumns = useMemo( + () => resolveColumnTypes(_rawRunColumns, backendAttributes), + [_rawRunColumns, backendAttributes], + ); + const _hookData = useMemo(() => ({ refetch: _loadRuns, pagination: paginationMeta, @@ -711,6 +721,13 @@ const _WorkflowsTab: React.FC = () => { const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); const lastPaginationParamsRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2Workflow') + .then(setBackendAttributes) + .catch(() => {}); + }, [request]); const _load = useCallback(async (paginationParams?: any) => { if (paginationParams !== undefined) { @@ -883,12 +900,11 @@ const _WorkflowsTab: React.FC = () => { return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api')); }, []); - const _columns: ColumnConfig[] = useMemo(() => [ - { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true }, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, { key: 'mandateId', label: t('Mandant'), - type: 'string', width: 140, sortable: true, filterable: true, @@ -897,7 +913,6 @@ const _WorkflowsTab: React.FC = () => { { key: 'featureInstanceId', label: t('Instanz'), - type: 'string', width: 140, sortable: true, filterable: true, @@ -906,7 +921,6 @@ const _WorkflowsTab: React.FC = () => { { key: 'active', label: t('Aktiv'), - type: 'boolean', width: 80, sortable: true, filterable: true, @@ -914,13 +928,11 @@ const _WorkflowsTab: React.FC = () => { { key: 'isRunning', label: t('Läuft'), - type: 'boolean', width: 80, }, { key: 'sysCreatedAt', label: t('Erstellt'), - type: 'number', width: 140, sortable: true, formatter: (v: number) => _formatTs(v), @@ -928,19 +940,22 @@ const _WorkflowsTab: React.FC = () => { { key: 'lastStartedAt', label: t('Zuletzt gestartet'), - type: 'number', width: 160, formatter: (v: number) => _formatTs(v), }, { key: 'runCount', label: t('Läufe'), - type: 'number', width: 80, formatter: (v: number) => (v != null ? String(v) : '0'), }, ], [t]); + const _columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + const _hookData = useMemo(() => ({ refetch: _load, handleDelete: (id: string) => _handleDelete(id), diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 7d70bce..ea45645 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -14,6 +14,10 @@ import { } from 'recharts'; import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa'; import api from '../api'; +import { useApiRequest } from '../hooks/useApi'; +import { fetchAttributes } from '../api/attributesApi'; +import type { AttributeDefinition } from '../api/attributesApi'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; import { useLanguage } from '../providers/language/LanguageContext'; import { useUserMandates } from '../hooks/useUserMandates'; import { useConfirm } from '../hooks/useConfirm'; @@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100; export const ComplianceAuditPage: React.FC = () => { const { t } = useLanguage(); + const { request } = useApiRequest(); + const [aiAuditAttrs, setAiAuditAttrs] = useState([]); + const [auditLogAttrs, setAuditLogAttrs] = useState([]); + const [neutAttrs, setNeutAttrs] = useState([]); const { fetchMandates } = useUserMandates(); const { confirm, ConfirmDialog } = useConfirm(); + useEffect(() => { + fetchAttributes(request, 'AiAuditLogEntry').then(setAiAuditAttrs).catch(() => setAiAuditAttrs([])); + fetchAttributes(request, 'AuditLogEntry').then(setAuditLogAttrs).catch(() => setAuditLogAttrs([])); + fetchAttributes(request, 'DataNeutralizerAttributesView').then(setNeutAttrs).catch(() => setNeutAttrs([])); + }, [request]); + const [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const [selectedMandateId, setSelectedMandateId] = useState(null); @@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => { // ── Column definitions ── - const aiLogColumns: ColumnConfig[] = useMemo(() => [ - { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, + const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [ + { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + searchable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, + key: 'instanceLabel', + label: t('Feature-Instanz'), + sortable: true, + filterable: true, + width: 160, formatter: (val: any, row: any) => val || row?.featureCode || '–', }, - { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, + { key: 'aiModel', label: t('AI-Modell'), sortable: true, filterable: true, width: 160 }, { - key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140, + key: 'aiProvider', + label: t('Provider / Typ'), + sortable: true, + filterable: true, + width: 140, formatter: (val: any, row: any) => { const provider = val || '–'; const op = row?.operationType; @@ -453,63 +479,110 @@ export const ComplianceAuditPage: React.FC = () => { }, }, { - key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110, - formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–', + key: 'priceCHF', + label: t('Kosten (CHF)'), + sortable: true, + width: 110, + formatter: (val: any) => (val != null ? Number(val).toFixed(4) : '–'), }, { - key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100, - formatter: (val: any) => val ? '✓' : '–', + key: 'neutralizationActive', + label: t('Neutralisierung'), + sortable: true, + width: 100, + formatter: (val: any) => (val ? '✓' : '–'), }, { - key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80, - formatter: (val: any) => val ? t('OK') : t('Fehler'), - cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, + key: 'success', + label: t('Status'), + sortable: true, + filterable: true, + width: 80, + formatter: (val: any) => (val ? t('OK') : t('Fehler')), + cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError), }, ], [t]); - const auditLogColumns: ColumnConfig[] = useMemo(() => [ - { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, + const aiLogColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawAiLogColumns, aiAuditAttrs), + [_rawAiLogColumns, aiAuditAttrs], + ); + + const _rawAuditLogColumns: ColumnConfig[] = useMemo(() => [ + { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + searchable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110, + key: 'category', + label: t('Kategorie'), + sortable: true, + filterable: true, + width: 110, cellClassName: (val: any) => { const color = _CATEGORY_COLORS[val as string]; return color ? styles[`cat_${val}`] || '' : ''; }, formatter: (val: any) => val || '–', }, - { key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 }, - { key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 }, + { key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 }, + { key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 }, + { key: 'details', label: t('Details'), searchable: true, width: 250 }, { - key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70, - formatter: (val: any) => val ? '✓' : '✗', - cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, + key: 'success', + label: t('Status'), + sortable: true, + width: 70, + formatter: (val: any) => (val ? '✓' : '✗'), + cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError), }, - { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 }, + { key: 'ipAddress', label: t('IP'), width: 120 }, ], [t]); - const neutColumns: ColumnConfig[] = useMemo(() => [ - { key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 }, - { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 }, - { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, + const auditLogColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs), + [_rawAuditLogColumns, auditLogAttrs], + ); + + const _rawNeutColumns: ColumnConfig[] = useMemo(() => [ + { key: 'placeholder', label: t('Platzhalter'), sortable: true, searchable: true, width: 220 }, + { key: 'originalText', label: t('Originaltext'), sortable: true, searchable: true, width: 240 }, + { key: 'patternType', label: t('Kategorie'), sortable: true, filterable: true, width: 120 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + filterable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, - formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'), + key: 'instanceLabel', + label: t('Feature-Instanz'), + sortable: true, + filterable: true, + width: 160, + formatter: (val: any, row: any) => val || (row?.featureInstanceId ? `NA(${row.featureInstanceId})` : '–'), }, { - key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140, - formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', + key: 'fileId', + label: t('Datei'), + sortable: true, + width: 140, + formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'), }, ], [t]); + const neutColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawNeutColumns, neutAttrs), + [_rawNeutColumns, neutAttrs], + ); + // ── fetchFilterValues for autofilter dropdowns ── const _makeFetchFilterValues = useCallback( diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 4fbd307..7ac9bab 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { ChatbotConfigSection } from './ChatbotConfigSection'; import { TextField } from '../../components/UiComponents/TextField'; import styles from './Admin.module.css'; @@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); const { loadFeatures } = useFeatureStore(); + const { request } = useApiRequest(); // State const [mandates, setMandates] = useState([]); @@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => { } }, [selectedMandateId, fetchInstances]); - // Table columns - const columns = useMemo(() => [ - { key: 'label', label: t('Name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 }, - { key: 'featureCode', label: t('Feature'), type: 'string' as const, sortable: true, filterable: true, width: 150, - render: (value: string) => { + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 }, + { + key: 'featureCode', + label: t('Feature'), + sortable: true, + filterable: true, + width: 150, + formatter: (value: string) => { const feature = features.find(f => f.code === value); - return feature ? (feature.label || value) : value; - } + const label = feature ? (feature.label || value) : value; + return label; + }, }, - { key: 'enabled', label: t('Aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 }, + { key: 'enabled', label: t('Aktiv'), sortable: true, filterable: true, width: 80 }, ], [features, t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Form attributes from backend - merge with dynamic feature options // Exclude featureCode, config, and label since we handle them separately const createFields: AttributeDefinition[] = useMemo(() => { diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index b91937d..0fc90f6 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useFeatureStore } from '../../stores/featureStore'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); const { loadFeatures } = useFeatureStore(); + const { request } = useApiRequest(); + const [backendAttributes, setBackendAttributes] = useState([]); // Combined instance option type interface CombinedInstanceOption { @@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { return selectedCombinedKey.split(':')[1] || ''; }, [selectedCombinedKey]); + useEffect(() => { + fetchAttributes(request, 'FeatureAccessView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [request]); + // Load mandates and features on mount, then build combined options useEffect(() => { fetchFeatures(); @@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { return allUsers.filter(u => !existingUserIds.has(u.id)); }, [allUsers, instanceUsers]); - // Table columns - const columns = useMemo(() => [ + const _rawColumns: ColumnConfig[] = useMemo(() => [ { key: 'username', label: t('Benutzername'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'email', label: t('E-Mail'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'fullName', label: t('Vollständiger Name'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'roleLabels', label: t('Rollen'), - type: 'text' as const, sortable: false, filterable: false, searchable: true, width: 200, - render: (value: string[]) => { + formatter: (value: string[]) => { if (!value || value.length === 0) return '-'; return value.join(', '); }, @@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'enabled', label: t('Aktiv'), - type: 'boolean' as const, sortable: true, filterable: true, searchable: false, @@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { }, ], [t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Dynamic options for forms (users and roles) const userOptions = useMemo(() => availableUsers.map(u => ({ diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index 48227b5..c1259ac 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules'; import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); + const [roleTableAttributes, setRoleTableAttributes] = useState([]); + // State const [features, setFeatures] = useState([]); const [selectedFeatureCode, setSelectedFeatureCode] = useState(''); @@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [permissionsRole, setPermissionsRole] = useState(null); + useEffect(() => { + fetchAttributes(request, 'Role') + .then(setRoleTableAttributes) + .catch(() => setRoleTableAttributes([])); + }, [request]); + // Load features on mount useEffect(() => { const loadFeatures = async () => { @@ -130,40 +143,41 @@ export const AdminFeatureRolesPage: React.FC = () => { return String(value); }; - // Table columns - const columns = useMemo(() => [ - { - key: 'roleLabel', - label: t('Rollen-Label'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, - width: 180 + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'roleLabel', + label: t('Rollen-Label'), + sortable: true, + filterable: true, + searchable: true, + width: 180, }, - { - key: 'description', - label: t('Beschreibung'), - type: 'string' as const, - sortable: false, + { + key: 'description', + label: t('Beschreibung'), + sortable: false, width: 300, - formatter: (value: string) => getTextValue(value) + formatter: (value: string) => getTextValue(value), }, - { - key: 'featureCode', - label: t('Feature'), - type: 'string' as const, - sortable: true, - filterable: true, + { + key: 'featureCode', + label: t('Feature'), + sortable: true, + filterable: true, width: 120, formatter: (value: string) => ( {value} - ) + ), }, ], [t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, roleTableAttributes), + [_rawColumns, roleTableAttributes], + ); + // Form attributes for create const createFields: AttributeDefinition[] = useMemo(() => { const fields: AttributeDefinition[] = [ diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 2f76da0..61c64f8 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); const { invitations, loading, @@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => { } }; loadMandates(); - // Fetch Invitation attributes from backend - api.get('/api/attributes/Invitation').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'Invitation') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin) useEffect(() => { @@ -84,25 +86,22 @@ export const AdminInvitationsPage: React.FC = () => { }); }; - // Table columns - const columns = useMemo(() => [ - { - key: 'targetUsername', - label: t('Benutzername'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'targetUsername', + label: t('Benutzername'), + sortable: true, + filterable: true, + searchable: true, width: 150, }, - { - key: 'email', - label: t('E-Mail'), - type: 'string' as const, - sortable: true, - filterable: true, + { + key: 'email', + label: t('E-Mail'), + sortable: true, + filterable: true, width: 180, - render: (value: string, row: Invitation) => { + formatter: (value: string, row: Invitation) => { const emailText = value || '-'; const emailSent = (row as any).emailSent; return ( @@ -110,30 +109,28 @@ export const AdminInvitationsPage: React.FC = () => { {emailText} {emailSent && '✓'} ); - } + }, }, - { - key: 'roleIds', - label: t('Rollen'), - type: 'string', // Array rendered as string - sortable: false, - filterable: false, + { + key: 'roleIds', + label: t('Rollen'), + sortable: false, + filterable: false, width: 150, - render: (value: string[]) => { + formatter: (value: string[]) => { if (!value || value.length === 0) return '-'; - return value.map(roleId => { + return value.map((roleId) => { const role = roles.find(r => r.id === roleId); return role?.roleLabel || roleId; }).join(', '); - } - } as any, - { - key: 'expiresAt', - label: t('Gültig bis'), - type: 'number' as const, - sortable: true, + }, + }, + { + key: 'expiresAt', + label: t('Gültig bis'), + sortable: true, width: 150, - render: (value: number) => { + formatter: (value: number) => { const text = formatDate(value); const isExpired = value < Date.now() / 1000; return ( @@ -141,29 +138,32 @@ export const AdminInvitationsPage: React.FC = () => { {text} {isExpired && '(abgelaufen)'} ); - } + }, }, - { - key: 'currentUses', - label: t('Verwendet'), - type: 'string' as const, - sortable: true, + { + key: 'currentUses', + label: t('Verwendet'), + sortable: true, width: 100, - render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}` + formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`, }, - { - key: 'createdAt', - label: t('Erstellt'), - type: 'number' as const, - sortable: true, + { + key: 'sysCreatedAt', + label: t('Erstellt'), + sortable: true, width: 150, - render: (value: number) => formatDate(value) + formatter: (value: number) => formatDate(value), }, ], [roles, t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin) const createFields: AttributeDefinition[] = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId']; + const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId']; // Mandate-level roles (user, viewer, admin) - same as when adding mandate members const roleOptions = roles diff --git a/src/pages/admin/AdminLanguagesPage.tsx b/src/pages/admin/AdminLanguagesPage.tsx index 5e74919..ea77f94 100644 --- a/src/pages/admin/AdminLanguagesPage.tsx +++ b/src/pages/admin/AdminLanguagesPage.tsx @@ -8,6 +8,10 @@ import api from '../../api'; import axios from 'axios'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable'; import { useConfirm } from '../../hooks/useConfirm'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import type { AttributeDefinition } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './Admin.module.css'; @@ -39,44 +43,6 @@ type ProgressInfo = { keysTranslated?: number; }; -function _getColumns(t: (key: string) => string): ColumnConfig[] { - return [ - { key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 }, - { key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 }, - { - key: 'status', - label: t('Status'), - type: 'text', - sortable: true, - filterable: true, - width: 160, - formatter: (_val: any, row: any) => { - const r = row as LangRow; - if (r.updating) { - return ( - - - {t('wird aktualisiert…')} - - ); - } - if (r.status === 'generating') { - return ( - - - {t('wird erzeugt…')} - - ); - } - return r.status; - }, - }, - { key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 }, - { key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 }, - { key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 }, - ]; -} - // ISO 639 catalog (codes + native labels + priority order) is provided by the // gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here -- // any divergence between frontend and backend caused subtle bugs (e.g. user @@ -278,6 +244,8 @@ const _ProgressOverlay: React.FC<{ export const AdminLanguagesPage: React.FC = () => { const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage(); const { confirm, ConfirmDialog } = useConfirm(); + const { request } = useApiRequest(); + const [langSetAttributes, setLangSetAttributes] = useState([]); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -288,6 +256,12 @@ export const AdminLanguagesPage: React.FC = () => { const busyRef = useRef(false); const abortRef = useRef(null); + useEffect(() => { + fetchAttributes(request, 'UiLanguageSetView') + .then(setLangSetAttributes) + .catch(() => setLangSetAttributes([])); + }, [request]); + useEffect(() => { let cancelled = false; (async () => { @@ -393,6 +367,44 @@ export const AdminLanguagesPage: React.FC = () => { }); }, [rows, search]); + const columns = useMemo(() => { + const raw: ColumnConfig[] = [ + { key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 }, + { key: 'label', label: t('Bezeichnung'), sortable: true, filterable: true, width: 200 }, + { + key: 'status', + label: t('Status'), + sortable: true, + filterable: true, + width: 160, + formatter: (_val: any, row: any) => { + const r = row as LangRow; + if (r.updating) { + return ( + + + {t('wird aktualisiert…')} + + ); + } + if (r.status === 'generating') { + return ( + + + {t('wird erzeugt…')} + + ); + } + return r.status; + }, + }, + { key: 'uiCount', label: t('UI'), sortable: true, width: 80 }, + { key: 'gatewayCount', label: t('API'), sortable: true, width: 80 }, + { key: 'entriesCount', label: t('Gesamt'), sortable: true, width: 80 }, + ]; + return resolveColumnTypes(raw, langSetAttributes); + }, [t, langSetAttributes]); + const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]); const addChoices = useMemo(() => { @@ -869,7 +881,7 @@ export const AdminLanguagesPage: React.FC = () => {
{ const { t, currentLanguage } = useLanguage(); const navigate = useNavigate(); + const { request } = useApiRequest(); const { showError, showWarning } = useToast(); const { roles, @@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => { } }; loadMandates(); - // Fetch Role attributes from backend - api.get('/api/attributes/Role').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'Role') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load roles when mandate or scopeFilter changes useEffect(() => { @@ -102,32 +104,28 @@ export const AdminMandateRolesPage: React.FC = () => { return String(desc); }; - // Table columns - scopeType is now a backend-computed field - const columns = useMemo(() => [ - { - key: 'roleLabel', - label: t('Bezeichnung'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, - width: 150 + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'roleLabel', + label: t('Bezeichnung'), + sortable: true, + filterable: true, + searchable: true, + width: 150, }, - { - key: 'description', - label: t('Beschreibung'), - type: 'string' as const, - sortable: false, + { + key: 'description', + label: t('Beschreibung'), + sortable: false, filterable: false, width: 250, - formatter: (value: string) => getDescriptionText(value) + formatter: (value: string) => getDescriptionText(value), }, - { - key: 'scopeType', - label: t('Geltungsbereich'), - type: 'string' as const, - sortable: true, - filterable: true, + { + key: 'scopeType', + label: t('Geltungsbereich'), + sortable: true, + filterable: true, width: 140, formatter: (value: string) => { if (value === 'system') { @@ -149,10 +147,15 @@ export const AdminMandateRolesPage: React.FC = () => { {t('Mandant')} ); - } + }, }, ], [t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Form attributes from backend - for create form const createFields: AttributeDefinition[] = useMemo(() => { const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType']; diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 2ff9715..8d9f5fa 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); const { users, loading, @@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => { } }; loadMandates(); - // Fetch UserMandate attributes from backend (for table columns) - api.get('/api/attributes/UserMandate').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'UserMandateView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load users when mandate changes useEffect(() => { @@ -97,60 +99,57 @@ export const AdminUserMandatesPage: React.FC = () => { return allUsers.filter(u => !existingUserIds.has(u.id)); }, [allUsers, users]); - // Table columns - based on MandateUserInfo response structure - const columns = useMemo(() => { - return [ - { - key: 'username', - label: t('Benutzername'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 150, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'username', + label: t('Benutzername'), + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, + { + key: 'email', + label: t('E-Mail'), + sortable: true, + filterable: true, + searchable: true, + width: 200, + }, + { + key: 'fullName', + label: t('Vollständiger Name'), + sortable: true, + filterable: true, + searchable: true, + width: 180, + }, + { + key: 'roleLabels', + label: t('Rollen'), + sortable: false, + filterable: false, + searchable: true, + width: 200, + formatter: (value: string[]) => { + if (!value || value.length === 0) return '-'; + return value.join(', '); }, - { - key: 'email', - label: t('E-Mail'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 200, - }, - { - key: 'fullName', - label: t('Vollständiger Name'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 180, - }, - { - key: 'roleLabels', - label: t('Rollen'), - type: 'text' as any, - sortable: false, - filterable: false, - searchable: true, - width: 200, - render: (value: string[]) => { - if (!value || value.length === 0) return '-'; - return value.join(', '); - }, - }, - { - key: 'enabled', - label: t('Aktiv'), - type: 'boolean' as any, - sortable: true, - filterable: true, - searchable: false, - width: 80, - }, - ]; - }, [t]); + }, + { + key: 'enabled', + label: t('Aktiv'), + sortable: true, + filterable: true, + searchable: false, + width: 80, + }, + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); // Dynamic options for forms (users and roles) const userOptions = useMemo(() => diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index fcac187..20f350d 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -14,6 +14,7 @@ import styles from './Admin.module.css'; import { getUserDataCache } from '../../utils/userCache'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const; @@ -57,12 +58,11 @@ export const AdminUsersPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingUser, setEditingUser] = useState(null); - // Generate columns from attributes + // Generate columns from attributes; types from backend via resolveColumnTypes const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -71,6 +71,7 @@ export const AdminUsersPage: React.FC = () => { maxWidth: attr.maxWidth || 400, displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); // Check permissions diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 774b7e6..9636e82 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); @@ -54,13 +55,12 @@ export const ConnectionsPage: React.FC = () => { const columns = useMemo(() => { const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes']; - return (attributes || []) + const raw = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => { const col: any = { key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, + label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -71,13 +71,9 @@ export const ConnectionsPage: React.FC = () => { frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, }; - - if (attr.name === 'userId') { - col.label = t('Benutzer'); - } - return col; }); + return resolveColumnTypes(raw, attributes || []); }, [attributes, t]); // Check permissions diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index b7b4fe4..a65ad5b 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { getUserDataCache } from '../../utils/userCache'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; interface UserFile { id: string; @@ -203,7 +204,6 @@ export const FilesPage: React.FC = () => { .map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -217,7 +217,6 @@ export const FilesPage: React.FC = () => { cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), - type: 'text' as any, sortable: true, filterable: true, searchable: true, @@ -226,7 +225,7 @@ export const FilesPage: React.FC = () => { maxWidth: 250, displayField: 'sysCreatedByLabel', } as any); - return cols; + return resolveColumnTypes(cols, attributes || []); }, [attributes, t]); const canCreate = permissions?.create !== 'n'; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index e274393..3fa1bdf 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; interface Prompt { id: string; @@ -76,7 +77,6 @@ export const PromptsPage: React.FC = () => { .map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -92,7 +92,6 @@ export const PromptsPage: React.FC = () => { cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), - type: 'text' as any, sortable: true, filterable: true, searchable: true, @@ -104,7 +103,7 @@ export const PromptsPage: React.FC = () => { frontendFormatLabels: undefined, }); - return cols; + return resolveColumnTypes(cols, attributes || []); }, [attributes, t]); // Check permissions diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index dc2a81c..f6d7403 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -1,7 +1,11 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions'; import { useConfirm } from '../../hooks/useConfirm'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import type { AttributeDefinition } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import api from '../../api'; import styles from './Billing.module.css'; @@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext'; const _TERMINAL_STATUSES = new Set(['EXPIRED']); -function _getColumns(t: (key: string) => string): ColumnConfig[] { - return [ - { key: 'mandateName', label: t('Mandant'), type: 'text', sortable: true, filterable: true, width: 180 }, - { key: 'planTitle', label: t('Plan'), type: 'text', sortable: true, filterable: true, width: 180 }, - { key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 110 }, - { key: 'recurring', label: t('Wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 }, - { key: 'activeUsers', label: t('Benutzer'), type: 'number', sortable: true, width: 70 }, - { key: 'activeInstances', label: t('Module'), type: 'number', sortable: true, width: 90 }, - { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), type: 'number', sortable: true, width: 140 }, - { key: 'startedAt', label: t('Gestartet'), type: 'date', sortable: true, filterable: true, width: 130 }, - { key: 'currentPeriodEnd', label: t('Periodenende'), type: 'date', sortable: true, filterable: true, width: 130 }, - { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), type: 'number', sortable: true, width: 100 }, - { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), type: 'number', sortable: true, width: 110 }, - ]; -} - const AdminSubscriptionsPage: React.FC = () => { - const { t } = useLanguage(); + const { t } = useLanguage(); + const { request } = useApiRequest(); + const [backendAttributes, setBackendAttributes] = useState([]); const { confirm, ConfirmDialog } = useConfirm(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); + useEffect(() => { + fetchAttributes(request, 'MandateSubscriptionView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [request]); + + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 }, + { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 }, + { key: 'activeInstances', label: t('Module'), sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), sortable: true, width: 140 }, + { key: 'startedAt', label: t('Gestartet'), sortable: true, filterable: true, width: 130 }, + { key: 'currentPeriodEnd', label: t('Periodenende'), sortable: true, filterable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), sortable: true, width: 110 }, + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + const _handleForceCancel = useCallback(async (row: any) => { const ok = await confirm( t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }), @@ -44,7 +59,7 @@ const AdminSubscriptionsPage: React.FC = () => { } catch (err) { console.error('Force cancel failed:', err); } - }, [confirm, refetch]); + }, [confirm, refetch, t]); return (
@@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
{ const { t } = useLanguage(); + const { request } = useApiRequest(); + const [billingTxnAttributes, setBillingTxnAttributes] = useState([]); const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -338,6 +344,12 @@ export const BillingDataView: React.FC = () => { const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); + useEffect(() => { + fetchAttributes(request, 'BillingTransactionView') + .then(setBillingTxnAttributes) + .catch(() => setBillingTxnAttributes([])); + }, [request]); + // Unified scope params -- single source of truth for all tab API calls // "nur meine Daten" is an additional filter on top of the dropdown scope const _scopeParams = useMemo((): Record => { @@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => { fetchFilterValues: _fetchTransactionFilterValues, }), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]); - // Table column definitions - const columns: ColumnConfig[] = useMemo(() => [ - { key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 }, - { key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, - { key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, - { key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 }, - { key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 }, - { key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 }, - { key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 }, + const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [ + { key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 }, + { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'userName', label: t('Benutzer'), sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'transactionType', label: t('Typ'), sortable: true, filterable: true, width: 100 }, + { key: 'description', label: t('Beschreibung'), searchable: true, width: 250 }, + { key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 }, + { key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 }, + { key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 }, + { key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 }, ], [t]); + const columns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes), + [_rawTransactionColumns, billingTxnAttributes], + ); + const totalBalance = useMemo(() => { const filtered = selectedScope === 'personal' || selectedScope === 'all' ? balances diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index bf2b575..db9c174 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -145,7 +145,7 @@ const TransactionTable: React.FC = ({ transactions }) => {transactions.map((txn) => ( - {formatDate(txn.createdAt)} + {formatDate(txn.sysCreatedAt)} {txn.mandateName || '-'} diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index 533513f..b176ff8 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -58,7 +58,7 @@ const TransactionRow: React.FC = ({ transaction }) => { return ( - {formatDate(transaction.createdAt)} + {formatDate(transaction.sysCreatedAt)} {transaction.mandateName || '-'} diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx index 644f2ef..3e9abb1 100644 --- a/src/pages/billing/BillingUserView.tsx +++ b/src/pages/billing/BillingUserView.tsx @@ -239,7 +239,7 @@ const UserTransactionTable: React.FC = ({ {filteredTransactions.map((txn) => ( - {formatDate(txn.createdAt)} + {formatDate(txn.sysCreatedAt)} {txn.mandateName || '-'} {txn.userName || '-'} diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx index 9737eab..06dd2e5 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx @@ -22,6 +22,9 @@ import { type AutoWorkflowTemplate, type AutoTemplateScope, } from '../../../api/workflowApi'; +import { fetchAttributes } from '../../../api/attributesApi'; +import type { AttributeDefinition } from '../../../api/attributesApi'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; import { useToast } from '../../../contexts/ToastContext'; import { formatUnixTimestamp } from '../../../utils/time'; import styles from '../../../pages/admin/Admin.module.css'; @@ -68,6 +71,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { const [sharingId, setSharingId] = useState(null); const [paginationMeta, setPaginationMeta] = useState(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'AutoWorkflow') + .then(setBackendAttributes) + .catch(() => {}); + }, [request]); const load = useCallback(async (paginationParams?: any) => { if (!instanceId) return; @@ -173,20 +183,18 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { [mandateId, instanceId, navigate] ); - const columns: ColumnConfig[] = useMemo( + const _rawColumns: ColumnConfig[] = useMemo( () => [ - { key: 'label', label: t('Vorlage'), type: 'string', width: 220, sortable: true }, + { key: 'label', label: t('Vorlage'), width: 220, sortable: true }, { key: 'templateScope', label: t('Bereich'), - type: 'string', width: 100, formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—', }, { key: 'sharedReadOnly', label: t('Freigegeben'), - type: 'boolean', width: 100, formatter: (v: boolean) => v ? ( @@ -198,14 +206,12 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { { key: 'sysCreatedBy', label: t('Erstellt von'), - type: 'string', width: 140, displayField: 'sysCreatedByLabel', }, { key: 'sysCreatedAt', label: t('Erstellt'), - type: 'number', width: 140, formatter: (v: number) => _formatTs(v), }, @@ -213,6 +219,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { [t, scopeLabels], ); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + if (!instanceId) { return (
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx index 4d0c0ad..d8511c1 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx @@ -6,7 +6,7 @@ * Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger). */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa'; import { usePrompt } from '../../../hooks/usePrompt'; @@ -26,6 +26,9 @@ import { type Automation2Workflow, type WorkflowFileEnvelope, } from '../../../api/workflowApi'; +import { fetchAttributes } from '../../../api/attributesApi'; +import type { AttributeDefinition } from '../../../api/attributesApi'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; import { useToast } from '../../../contexts/ToastContext'; import { formatUnixTimestamp } from '../../../utils/time'; import styles from '../../../pages/admin/Admin.module.css'; @@ -64,6 +67,13 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { const [paginationMeta, setPaginationMeta] = useState(null); const [importing, setImporting] = useState(false); const importFileInputRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2Workflow') + .then(setBackendAttributes) + .catch(() => {}); + }, [request]); const load = useCallback(async (paginationParams?: any) => { if (!instanceId) return; @@ -251,12 +261,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { [instanceId, request, showSuccess, showError, load, t], ); - const columns: ColumnConfig[] = [ - { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true }, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Workflow'), width: 200, sortable: true }, { key: 'active', label: t('Aktiv (Spalte)'), - type: 'boolean', width: 80, formatter: (value: boolean) => value !== false ? ( @@ -268,7 +277,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { { key: 'isRunning', label: t('läuft'), - type: 'boolean', width: 80, formatter: (value: boolean) => value ? ( @@ -280,7 +288,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { { key: 'stuckAtNodeLabel', label: t('steht bei'), - type: 'string', width: 160, formatter: (value: string, row: Automation2Workflow) => row.isRunning && (value || row.stuckAtNodeId) @@ -290,25 +297,27 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { { key: 'createdAt', label: t('Erstellt'), - type: 'number', width: 140, formatter: (v: number) => formatTs(v), }, { key: 'lastStartedAt', label: t('zuletzt gestartet'), - type: 'number', width: 160, formatter: (v: number) => formatTs(v), }, { key: 'runCount', label: t('Läufe'), - type: 'number', width: 80, formatter: (v: number) => (v != null ? String(v) : '0'), }, - ]; + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); const hookData = { refetch: load, diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 80fc0dd..e6d77f8 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const RealEstateParcelsView: React.FC = () => { const { t } = useLanguage(); @@ -54,10 +55,9 @@ export const RealEstateParcelsView: React.FC = () => { }, [instanceId, refetch]); const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as 'string' | 'number' | 'date' | 'boolean', sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -66,6 +66,7 @@ export const RealEstateParcelsView: React.FC = () => { maxWidth: attr.maxWidth || 400, displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); const canCreate = permissions?.create !== 'n'; diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 4d25a4b..9a92bc0 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const RealEstateProjectsView: React.FC = () => { const { t } = useLanguage(); @@ -52,10 +53,9 @@ export const RealEstateProjectsView: React.FC = () => { }, [instanceId, refetch]); const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean', sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -64,6 +64,7 @@ export const RealEstateProjectsView: React.FC = () => { maxWidth: attr.maxWidth || 400, displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); const canCreate = permissions?.create !== 'n'; diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index 4bbdd3b..3a8d24c 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -23,6 +23,7 @@ import api from '../../../api'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const TrusteeDocumentsView: React.FC = () => { const { t } = useLanguage(); @@ -70,7 +71,6 @@ export const TrusteeDocumentsView: React.FC = () => { const allCols = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -88,7 +88,7 @@ export const TrusteeDocumentsView: React.FC = () => { for (const col of allCols) { if (byKey.has(col.key)) ordered.push(col); } - return ordered; + return resolveColumnTypes(ordered, attributes || []); }, [attributes]); // Check permissions diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index 749fc41..83b45c8 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -14,6 +14,7 @@ import { FaSync } from 'react-icons/fa'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const TrusteePositionDocumentsView: React.FC = () => { const { t } = useLanguage(); @@ -56,12 +57,11 @@ export const TrusteePositionDocumentsView: React.FC = () => { // Exclude system fields from table columns const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; - return attributes + const raw = attributes .filter((attr: any) => !excludedFields.includes(attr.name)) .map((attr: any) => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -70,6 +70,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { maxWidth: attr.maxWidth || 400, displayField: attr.displayField, })); + return resolveColumnTypes(raw, attributes); }, [attributes]); // Check permissions (general level) diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 66122c2..bb5875a 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -26,6 +26,7 @@ import { formatAmount, formatPercent } from '../../../utils/formatAmount'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const TrusteePositionsView: React.FC = () => { const { t } = useLanguage(); @@ -285,7 +286,6 @@ export const TrusteePositionsView: React.FC = () => { const col: ColumnConfig = { key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -320,7 +320,7 @@ export const TrusteePositionsView: React.FC = () => { const col = byKey.get(key); if (col) ordered.push(col); } - return ordered; + return resolveColumnTypes(ordered, attributes || []); }, [attributes, belegeColumn, syncStatusColumn]); // Check permissions diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx index 1b6f5bf..75c00a1 100644 --- a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx +++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx @@ -25,6 +25,7 @@ import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGene import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useInstanceId } from '../../../../hooks/useCurrentInstance'; import adminStyles from '../../../admin/Admin.module.css'; +import { resolveColumnTypes } from '../../../../utils/columnTypeResolver'; export interface TrusteeDataTabProps { /** Result of the entity hook factory call (see `useTrustee.ts`). */ @@ -117,12 +118,11 @@ export const TrusteeDataTab: React.FC = ({ const columns = useMemo(() => { const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]); - return (attributes || []) + const raw = (attributes || []) .filter((attr: any) => !hidden.has(attr.name)) .map((attr: any) => ({ key: attr.name, label: attr.label || attr.name, - type: (attr.type as any) || 'text', sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, @@ -133,6 +133,7 @@ export const TrusteeDataTab: React.FC = ({ frontendFormat: attr.frontendFormat, frontendFormatLabels: attr.frontendFormatLabels, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes, hiddenColumns]); const formAttributes = useMemo(() => { diff --git a/src/utils/attributeTypeMapper.ts b/src/utils/attributeTypeMapper.ts index f9e3517..1d18dcc 100644 --- a/src/utils/attributeTypeMapper.ts +++ b/src/utils/attributeTypeMapper.ts @@ -24,7 +24,9 @@ export type AttributeType = | 'string' | 'enum' | 'slug' - | 'readonly'; + | 'readonly' + | 'object' + | 'json'; export type InputComponentType = | 'text' @@ -82,6 +84,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom return 'multiselect'; case 'integer': + case 'int': case 'number': case 'float': return 'number'; @@ -113,6 +116,10 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom case 'readonly': return 'text'; // Default to text for readonly, but should be rendered as readonly + + case 'object': + case 'json': + return 'textarea'; default: // Default fallback to text input @@ -124,7 +131,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom * Determines if an attribute type should render as a textarea */ export function isTextareaType(attributeType: AttributeType): boolean { - return attributeType === 'textarea'; + return attributeType === 'textarea' || attributeType === 'object' || attributeType === 'json'; } /** @@ -166,7 +173,12 @@ export function isFileType(attributeType: AttributeType): boolean { * Determines if an attribute type should render as a number input */ export function isNumberType(attributeType: AttributeType): boolean { - return attributeType === 'integer' || attributeType === 'number' || attributeType === 'float'; + return ( + attributeType === 'integer' + || attributeType === 'int' + || attributeType === 'number' + || attributeType === 'float' + ); } /** @@ -176,6 +188,25 @@ export function isDateTimeType(attributeType: AttributeType): boolean { return attributeType === 'timestamp' || attributeType === 'date' || attributeType === 'time'; } +/** + * Subset of AttributeType suitable for user-facing form fields (workflow start forms, + * input forms, field builders). Components render labels via t(FORM_FIELD_TYPE_LABELS[ft]). + */ +export const FORM_FIELD_TYPES: AttributeType[] = [ + 'text', 'textarea', 'number', 'email', 'date', 'boolean', 'select', 'checkbox', +]; + +export const FORM_FIELD_TYPE_LABELS: Record = { + text: 'Text', + textarea: 'Mehrzeilig', + number: 'Zahl', + email: 'E-Mail', + date: 'Datum', + boolean: 'Ja/Nein', + select: 'Auswahl', + checkbox: 'Kontrollkästchen', +}; + /** * Gets the default value for an attribute type */ diff --git a/src/utils/columnTypeResolver.ts b/src/utils/columnTypeResolver.ts new file mode 100644 index 0000000..7ce5e22 --- /dev/null +++ b/src/utils/columnTypeResolver.ts @@ -0,0 +1,57 @@ +/** + * Resolves column types from backend attribute definitions. + * + * Pages define column configs with UI metadata (label, width, formatter, etc.) + * but omit the `type` field. This utility merges the backend-provided attribute + * type into each column, ensuring a single source of truth for column types. + */ + +import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; +import type { AttributeType } from './attributeTypeMapper'; + +export interface AttributeLike { + name: string; + type?: string; + displayField?: string; + frontendFormat?: string; + frontendFormatLabels?: string[]; +} + +/** + * Merge backend attribute types into page-defined column configs. + * + * For each column, the `type` is resolved from the matching backend attribute + * (by `key === attr.name`). Page-level overrides for `type` are preserved only + * when no backend attribute is available (graceful degradation during loading). + */ +export function resolveColumnTypes( + columns: ColumnConfig[], + attributes: AttributeLike[], +): ColumnConfig[] { + if (!attributes || attributes.length === 0) return columns; + + const attrMap = new Map(); + for (const attr of attributes) { + attrMap.set(attr.name, attr); + } + + return columns.map((col) => { + const attr = attrMap.get(col.key); + if (!attr) return col; + + const merged: ColumnConfig = { ...col }; + if (attr.type) { + merged.type = attr.type as AttributeType; + } + if (attr.displayField && !col.displayField) { + merged.displayField = attr.displayField; + } + if (attr.frontendFormat && !col.frontendFormat) { + merged.frontendFormat = attr.frontendFormat; + } + if (attr.frontendFormatLabels && !col.frontendFormatLabels) { + merged.frontendFormatLabels = attr.frontendFormatLabels; + } + return merged; + }); +}