= ({
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
- const ts = wf.lastStartedAt || wf.createdAt;
+ const ts = wf.lastStartedAt || wf.sysCreatedAt;
return (
): string => {
// Types for the FormGeneratorTable
export interface ColumnConfig {
key: string;
- label: string;
+ /** Header label. Optional — when omitted, `resolveColumnTypes` fills it from the
+ * backend Pydantic field's `label`. Set explicitly only to override. */
+ label?: string;
type?: AttributeType;
width?: number;
minWidth?: number;
@@ -129,6 +131,11 @@ export interface ColumnConfig {
// Pre-translated label tokens for binary/categorical cells, e.g. ["Ja", "-", "Nein"].
// Resolved server-side via i18n so the FE never needs another translation hop.
frontendFormatLabels?: string[];
+ // Backend-provided enum options (frontend_options on a Pydantic Field).
+ // When present and column.type is `select`, the cell renderer translates the
+ // raw value into the matching label. Pages MUST NOT hardcode value→label
+ // mappings — they must come from the Pydantic model.
+ options?: Array<{ value: string | number; label: string }>;
}
export interface FormGeneratorTableProps {
@@ -1219,9 +1226,10 @@ export function FormGeneratorTable>({
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]);
// Get unique values for a column (for filter dropdown)
- // Sources: 1) column.filterOptions (static enum)
- // 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
- // 3) data — ONLY when no backend pagination (data = full dataset)
+ // Sources: 1) column.filterOptions (static enum, page-defined)
+ // 2) column.options (backend-provided frontend_options on Pydantic field)
+ // 3) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
+ // 4) data — ONLY when no backend pagination (data = full dataset)
// With backend pagination, data is a single page, so extracting filter
// values from it would be incomplete and misleading.
const getUniqueValuesForColumn = useCallback((columnKey: string): FilterValue[] => {
@@ -1231,6 +1239,10 @@ export function FormGeneratorTable>({
return column.filterOptions;
}
+ if (column?.options && column.options.length > 0) {
+ return column.options.map((o) => ({ value: String(o.value), label: o.label }));
+ }
+
// displayField + local full dataset: { value, label } from enriched rows
if (column?.displayField && !supportsBackendPagination) {
const showKey = column.displayField;
@@ -1905,6 +1917,15 @@ export function FormGeneratorTable>({
return renderBooleanCell(value, column, row);
}
+ // Select / enum cells: translate raw value to backend-provided label.
+ // Pages MUST NOT hardcode value→label maps; they must declare
+ // `frontend_options` on the Pydantic field, and the API forwards them
+ // (already translated server-side via i18n) into `column.options`.
+ if (column.options && column.options.length > 0 && (value !== null && value !== undefined && value !== '')) {
+ const match = column.options.find((opt) => String(opt.value) === String(value));
+ if (match) return match.label;
+ }
+
// Check if this is an ID or hash field that should be truncated and copyable
// Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
const isId = isIdField(column.key);
@@ -2284,9 +2305,9 @@ export function FormGeneratorTable>({
column.sortable && handleSort(column.key)}
- title={column.label}
+ title={column.label ?? column.key}
>
- {column.label}
+ {column.label ?? column.key}
@@ -2298,7 +2319,7 @@ export function FormGeneratorTable>({
onClick={(e) => e.stopPropagation()}
>
-
{t('Filter')}: {column.label}
+
{t('Filter')}: {column.label ?? column.key}
{column.key in filters && (
>({
allValues={columnFilterValues}
activeFilter={filters[column.key]}
onSelect={(value) => handleFilter(column.key, value)}
- resolveLabel={column.filterLabelResolver}
+ resolveLabel={
+ column.filterLabelResolver
+ ?? (column.options && column.options.length > 0
+ ? (v: string) => {
+ const m = column.options!.find((o) => String(o.value) === String(v));
+ return m ? m.label : v;
+ }
+ : undefined)
+ }
/>
)}
>
diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts
index 61a6fd9..7377e3e 100644
--- a/src/hooks/useInvitations.ts
+++ b/src/hooks/useInvitations.ts
@@ -41,9 +41,11 @@ export interface Invitation {
maxUses: number;
currentUses: number;
inviteUrl: string;
- emailSent?: boolean;
- isExpired?: boolean;
- isUsedUp?: boolean;
+ // Backend-driven flags (computed @ Pydantic model + view enrichment)
+ emailSentFlag?: boolean;
+ emailSentAt?: number;
+ expiredFlag?: boolean;
+ usedUpFlag?: boolean;
}
export interface InvitationCreate {
diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts
index 83e0823..c9aad43 100644
--- a/src/hooks/useTrustee.ts
+++ b/src/hooks/useTrustee.ts
@@ -110,6 +110,11 @@ export interface AttributeDefinition {
interface TrusteeEntityConfig {
entityName: string;
+ /** Optional override: name of the *view* model (e.g. ``TrusteePositionView``)
+ * used purely for the `/attributes/...` lookup so synthetic display columns
+ * resolve via `resolveColumnTypes`. Falls back to `entityName` when absent.
+ * Permissions and CRUD operations always use `entityName`. */
+ attributesEntityName?: string;
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise;
fetchById: (request: any, instanceId: string, id: string) => Promise;
create: (request: any, instanceId: string, data: Partial) => Promise;
@@ -138,7 +143,8 @@ function _createTrusteeEntityHook(config: TrusteeEntit
if (!instanceId) return [];
try {
- const response = await api.get(`/api/trustee/${instanceId}/attributes/${config.entityName}`);
+ const attrEntity = config.attributesEntityName ?? config.entityName;
+ const response = await api.get(`/api/trustee/${instanceId}/attributes/${attrEntity}`);
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
@@ -571,6 +577,9 @@ export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documen
const positionConfig: TrusteeEntityConfig = {
entityName: 'TrusteePosition',
+ // Use the view model so the table picks up `syncStatus` / `syncErrorMessage`
+ // attributes (computed at the route layer from `TrusteeAccountingSync`).
+ attributesEntityName: 'TrusteePositionView',
fetchAll: fetchPositionsApi,
fetchById: fetchPositionByIdApi,
create: createPositionApi,
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx
index e0923f7..3941bd6 100644
--- a/src/pages/AutomationsDashboardPage.tsx
+++ b/src/pages/AutomationsDashboardPage.tsx
@@ -440,7 +440,7 @@ const _DashboardTab: React.FC = () => {
useEffect(() => {
fetchAttributes(request, 'AutoRun')
.then(setBackendAttributes)
- .catch(() => {});
+ .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); });
}, [request]);
const _loadMetrics = useCallback(async () => {
@@ -531,15 +531,6 @@ const _DashboardTab: React.FC = () => {
}
}, [showError, t]);
- const _STATUS_LABELS: Record = useMemo(() => ({
- running: t('Laufend'),
- completed: t('Abgeschlossen'),
- failed: t('Fehlgeschlagen'),
- cancelled: t('Abgebrochen'),
- paused: t('Pausiert'),
- stopped: t('Gestoppt'),
- }), [t]);
-
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
@@ -564,35 +555,24 @@ const _DashboardTab: React.FC = () => {
filterable: true,
displayField: 'instanceLabel',
},
+ { key: 'status', width: 110, sortable: true, filterable: true },
{
- key: 'status',
- label: t('Status'),
- width: 110,
- sortable: true,
- filterable: true,
- filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'],
- filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v,
- formatter: (v: string) => (
-
- {_STATUS_LABELS[v] || v}
-
- ),
- },
- {
- key: 'sysCreatedAt',
+ key: 'startedAt',
label: t('Gestartet'),
width: 150,
sortable: true,
+ filterable: true,
formatter: (v: number) => _formatTs(v),
},
{
- key: 'sysModifiedAt',
+ key: 'completedAt',
label: t('Beendet'),
width: 150,
sortable: true,
+ filterable: true,
formatter: (v: number) => _formatTs(v),
},
- ], [t, _STATUS_LABELS]);
+ ], [t]);
const _runColumns = useMemo(
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
@@ -675,7 +655,7 @@ const _DashboardTab: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
- initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
+ initialSort={[{ key: 'startedAt', direction: 'desc' }]}
apiEndpoint="/api/system/workflow-runs"
customActions={[
{
@@ -724,9 +704,9 @@ const _WorkflowsTab: React.FC = () => {
const [backendAttributes, setBackendAttributes] = useState([]);
useEffect(() => {
- fetchAttributes(request, 'Automation2Workflow')
+ fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
- .catch(() => {});
+ .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); });
}, [request]);
const _load = useCallback(async (paginationParams?: any) => {
@@ -740,7 +720,7 @@ const _WorkflowsTab: React.FC = () => {
if (activeFilter === 'active') params.active = true;
if (activeFilter === 'inactive') params.active = false;
- const defaultSort = [{ field: 'createdAt', direction: 'desc' }];
+ const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
const pag = {
page: effectiveParams?.page || 1,
pageSize: effectiveParams?.pageSize || 25,
@@ -929,24 +909,31 @@ const _WorkflowsTab: React.FC = () => {
key: 'isRunning',
label: t('Läuft'),
width: 80,
+ sortable: true,
+ filterable: true,
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
width: 140,
sortable: true,
+ filterable: true,
formatter: (v: number) => _formatTs(v),
},
{
key: 'lastStartedAt',
label: t('Zuletzt gestartet'),
width: 160,
+ sortable: true,
+ filterable: true,
formatter: (v: number) => _formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
+ sortable: true,
+ filterable: true,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
@@ -1000,7 +987,7 @@ const _WorkflowsTab: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
- initialSort={[{ key: 'createdAt', direction: 'desc' }]}
+ initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
apiEndpoint="/api/system/workflow-runs/workflows"
actionButtons={[
{
diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx
index ea45645..ab8c7b4 100644
--- a/src/pages/ComplianceAuditPage.tsx
+++ b/src/pages/ComplianceAuditPage.tsx
@@ -498,7 +498,6 @@ export const ComplianceAuditPage: React.FC = () => {
sortable: true,
filterable: true,
width: 80,
- formatter: (val: any) => (val ? t('OK') : t('Fehler')),
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
},
], [t]);
diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx
index 49270ec..7e4f41d 100644
--- a/src/pages/admin/AdminDatabaseHealthPage.tsx
+++ b/src/pages/admin/AdminDatabaseHealthPage.tsx
@@ -375,12 +375,20 @@ const OrphansTab: React.FC = () => {
const [downloading, setDownloading] = useState(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
+ // Default ON: deleted-user remnants belong to a dedicated purge workflow,
+ // not to generic FK cleanup. Hiding them by default prevents confusion
+ // (and accidental "Alle bereinigen" runs) when the SysAdmin scans for
+ // genuine FK drift.
+ const [excludeUserFks, setExcludeUserFks] = useState(true);
const [dbFilter, setDbFilter] = useState('');
const _fetchOrphans = useCallback(async () => {
try {
setLoading(true);
- const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
+ const qs = new URLSearchParams();
+ if (dbFilter) qs.set('db', dbFilter);
+ if (excludeUserFks) qs.set('excludeUserFks', 'true');
+ const params = qs.toString() ? `?${qs.toString()}` : '';
const res = await api.get(`/api/admin/database-health/orphans${params}`);
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
...o,
@@ -392,7 +400,7 @@ const OrphansTab: React.FC = () => {
} finally {
setLoading(false);
}
- }, [dbFilter]);
+ }, [dbFilter, excludeUserFks]);
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
@@ -515,7 +523,7 @@ const OrphansTab: React.FC = () => {
if (!ok) return;
setCleaningAll(true);
try {
- const res = await api.post('/api/admin/database-health/orphans/clean-all', { force });
+ const res = await api.post('/api/admin/database-health/orphans/clean-all', { force, excludeUserFks });
const results: CleanResult[] = res.data.results || [];
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
const errors = results.filter(r => r.error);
@@ -636,6 +644,19 @@ const OrphansTab: React.FC = () => {
{t('Nur Probleme')}
+
+
+ setExcludeUserFks(e.target.checked)}
+ />
+ {t('Ohne FK-Referenzen zu UserInDB.id')}
+
+
{t('Scan')}
diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx
index 61c64f8..1f1c73c 100644
--- a/src/pages/admin/AdminInvitationsPage.tsx
+++ b/src/pages/admin/AdminInvitationsPage.tsx
@@ -73,7 +73,7 @@ export const AdminInvitationsPage: React.FC = () => {
}
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
- // Format timestamp
+ // Format timestamp (used by URL modal only).
const formatDate = (timestamp: number) => {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
@@ -87,33 +87,12 @@ export const AdminInvitationsPage: React.FC = () => {
};
const _rawColumns: ColumnConfig[] = useMemo(() => [
- {
- key: 'targetUsername',
- label: t('Benutzername'),
- sortable: true,
- filterable: true,
- searchable: true,
- width: 150,
- },
- {
- key: 'email',
- label: t('E-Mail'),
- sortable: true,
- filterable: true,
- width: 180,
- formatter: (value: string, row: Invitation) => {
- const emailText = value || '-';
- const emailSent = (row as any).emailSent;
- return (
-
- {emailText} {emailSent && '✓'}
-
- );
- },
- },
+ { key: 'targetUsername', sortable: true, filterable: true, searchable: true, width: 150 },
+ { key: 'email', sortable: true, filterable: true, width: 180 },
+ { key: 'emailSentFlag', sortable: true, filterable: true, width: 90 },
+ { key: 'emailSentAt', sortable: true, filterable: true, width: 150 },
{
key: 'roleIds',
- label: t('Rollen'),
sortable: false,
filterable: false,
width: 150,
@@ -125,36 +104,18 @@ export const AdminInvitationsPage: React.FC = () => {
}).join(', ');
},
},
- {
- key: 'expiresAt',
- label: t('Gültig bis'),
- sortable: true,
- width: 150,
- formatter: (value: number) => {
- const text = formatDate(value);
- const isExpired = value < Date.now() / 1000;
- return (
-
- {text} {isExpired && '(abgelaufen)'}
-
- );
- },
- },
+ { key: 'expiresAt', sortable: true, filterable: true, width: 150 },
+ { key: 'expiredFlag', sortable: true, filterable: true, width: 90 },
{
key: 'currentUses',
- label: t('Verwendet'),
sortable: true,
+ filterable: true,
width: 100,
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
},
- {
- key: 'sysCreatedAt',
- label: t('Erstellt'),
- sortable: true,
- width: 150,
- formatter: (value: number) => formatDate(value),
- },
- ], [roles, t]);
+ { key: 'usedUpFlag', sortable: true, filterable: true, width: 90 },
+ { key: 'sysCreatedAt', sortable: true, filterable: true, width: 150 },
+ ], [roles]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
@@ -445,8 +406,8 @@ export const AdminInvitationsPage: React.FC = () => {
{t('verwendet werden.')}
{showUrlModal.email && (
-
- {showUrlModal.emailSent
+
+ {showUrlModal.emailSentFlag
? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
diff --git a/src/pages/admin/AdminLanguagesPage.tsx b/src/pages/admin/AdminLanguagesPage.tsx
index ea77f94..8e324ca 100644
--- a/src/pages/admin/AdminLanguagesPage.tsx
+++ b/src/pages/admin/AdminLanguagesPage.tsx
@@ -367,6 +367,45 @@ export const AdminLanguagesPage: React.FC = () => {
});
}, [rows, search]);
+ const _fetchFilterValues = useCallback(
+ async (columnKey: string, crossFilters?: Record): Promise<(string | null)[]> => {
+ let source = displayRows;
+ if (crossFilters && Object.keys(crossFilters).length > 0) {
+ source = source.filter((row) => {
+ for (const [key, val] of Object.entries(crossFilters)) {
+ if (val === undefined || val === null || val === '') continue;
+ const cell = (row as any)[key];
+ if (Array.isArray(val)) {
+ if (val.length > 0 && !val.includes(String(cell ?? ''))) return false;
+ } else if (String(cell ?? '') !== String(val)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+ const seen = new Set();
+ let hasEmpty = false;
+ for (const row of source) {
+ const v = (row as any)[columnKey];
+ if (v === undefined || v === null || v === '') {
+ hasEmpty = true;
+ continue;
+ }
+ seen.add(String(v));
+ }
+ const out: (string | null)[] = Array.from(seen).sort((a, b) => a.localeCompare(b));
+ if (hasEmpty) out.push(null);
+ return out;
+ },
+ [displayRows],
+ );
+
+ const _hookData = useMemo(
+ () => ({ fetchFilterValues: _fetchFilterValues }),
+ [_fetchFilterValues],
+ );
+
const columns = useMemo(() => {
const raw: ColumnConfig[] = [
{ key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
@@ -886,6 +925,7 @@ export const AdminLanguagesPage: React.FC = () => {
pagination={false}
selectable={false}
searchable={false}
+ hookData={_hookData}
customActions={[
{
id: 'sync-xx',
diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx
index 77db34d..007ad89 100644
--- a/src/pages/admin/AdminMandateRolesPage.tsx
+++ b/src/pages/admin/AdminMandateRolesPage.tsx
@@ -19,7 +19,7 @@ import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type Pagi
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
-import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
+import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
@@ -72,7 +72,7 @@ export const AdminMandateRolesPage: React.FC = () => {
}
};
loadMandates();
- fetchAttributes(request, 'Role')
+ fetchAttributes(request, 'RoleView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
@@ -105,51 +105,17 @@ export const AdminMandateRolesPage: React.FC = () => {
};
const _rawColumns: ColumnConfig[] = useMemo(() => [
- {
- key: 'roleLabel',
- label: t('Bezeichnung'),
- sortable: true,
- filterable: true,
- searchable: true,
- width: 150,
- },
+ { key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
{
key: 'description',
- label: t('Beschreibung'),
sortable: false,
filterable: false,
width: 250,
formatter: (value: string) => getDescriptionText(value),
},
- {
- key: 'scopeType',
- label: t('Geltungsbereich'),
- sortable: true,
- filterable: true,
- width: 140,
- formatter: (value: string) => {
- if (value === 'system') {
- return (
-
- {t('System-Template')}
-
- );
- }
- if (value === 'global') {
- return (
-
- {t('Template')}
-
- );
- }
- return (
-
- {t('Mandant')}
-
- );
- },
- },
- ], [t]);
+ { key: 'scopeType', sortable: true, filterable: true, width: 160 },
+ { key: 'userCount', sortable: true, filterable: true, width: 100 },
+ ], []);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
diff --git a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
index d1c35c0..89d28ed 100644
--- a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
+++ b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
@@ -38,7 +38,7 @@ interface DispatchResult {
username?: string;
success: boolean;
error?: string;
- emailSent?: boolean;
+ emailSentFlag?: boolean;
}
// =============================================================================
@@ -254,7 +254,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
email: emailTrim,
username: inv.username,
success: true,
- emailSent: result.data?.emailSent,
+ emailSentFlag: result.data?.emailSentFlag,
});
} else {
results.push({
@@ -731,7 +731,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{r.success ? t('Erfolgreich') : r.error || t('Fehler')}
- {r.emailSent ? t('Ja') : t('—')}
+ {r.emailSentFlag ? t('Ja') : t('—')}
))}
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
index 06dd2e5..8cb5615 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
@@ -74,9 +74,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
const [backendAttributes, setBackendAttributes] = useState([]);
useEffect(() => {
- fetchAttributes(request, 'AutoWorkflow')
+ fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
- .catch(() => {});
+ .catch((err) => {
+ console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
+ });
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
@@ -185,38 +187,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
const _rawColumns: ColumnConfig[] = useMemo(
() => [
- { key: 'label', label: t('Vorlage'), width: 220, sortable: true },
- {
- key: 'templateScope',
- label: t('Bereich'),
- width: 100,
- formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—',
- },
- {
- key: 'sharedReadOnly',
- label: t('Freigegeben'),
- width: 100,
- formatter: (v: boolean) =>
- v ? (
- {t('Ja')}
- ) : (
- {t('Nein')}
- ),
- },
- {
- key: 'sysCreatedBy',
- label: t('Erstellt von'),
- width: 140,
- displayField: 'sysCreatedByLabel',
- },
- {
- key: 'sysCreatedAt',
- label: t('Erstellt'),
- width: 140,
- formatter: (v: number) => _formatTs(v),
- },
+ { key: 'label', label: t('Vorlage'), width: 220, sortable: true, filterable: true },
+ { key: 'templateScope', width: 100, sortable: true, filterable: true },
+ { key: 'sharedReadOnly', width: 100, sortable: true, filterable: true },
+ { key: 'sysCreatedBy', width: 140, sortable: true, filterable: true, displayField: 'sysCreatedByLabel' },
+ { key: 'sysCreatedAt', width: 140, sortable: true, filterable: true, formatter: (v: number) => _formatTs(v) },
],
- [t, scopeLabels],
+ [t],
);
const columns = useMemo(
@@ -274,6 +251,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
+ apiEndpoint={`/api/workflows/${instanceId}/templates`}
actionButtons={[
{
type: 'edit',
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
index d8511c1..0f12750 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
@@ -70,9 +70,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const [backendAttributes, setBackendAttributes] = useState([]);
useEffect(() => {
- fetchAttributes(request, 'Automation2Workflow')
+ fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
- .catch(() => {});
+ .catch((err) => {
+ console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
+ });
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
@@ -262,54 +264,42 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
);
const _rawColumns: ColumnConfig[] = useMemo(() => [
- { key: 'label', label: t('Workflow'), width: 200, sortable: true },
- {
- key: 'active',
- label: t('Aktiv (Spalte)'),
- width: 80,
- formatter: (value: boolean) =>
- value !== false ? (
- Ja
- ) : (
- Nein
- ),
- },
- {
- key: 'isRunning',
- label: t('läuft'),
- width: 80,
- formatter: (value: boolean) =>
- value ? (
- {t('Ja')}
- ) : (
- Nein
- ),
- },
+ { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
+ { key: 'active', width: 80, sortable: true, filterable: true },
+ { key: 'isRunning', width: 80, sortable: true, filterable: true },
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
width: 160,
+ sortable: false,
+ filterable: false,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
? value || row.stuckAtNodeId || '—'
: '—',
},
{
- key: 'createdAt',
+ key: 'sysCreatedAt',
label: t('Erstellt'),
width: 140,
+ sortable: true,
+ filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
width: 160,
+ sortable: true,
+ filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
+ sortable: true,
+ filterable: true,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
@@ -390,6 +380,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
+ apiEndpoint={`/api/workflows/${instanceId}/workflows`}
actionButtons={[
{
type: 'edit',
diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx
index e6d77f8..983cdb3 100644
--- a/src/pages/views/realestate/RealEstateParcelsView.tsx
+++ b/src/pages/views/realestate/RealEstateParcelsView.tsx
@@ -179,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
+ apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
actionButtons={[
...(canUpdate
? [
diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx
index 9a92bc0..f531109 100644
--- a/src/pages/views/realestate/RealEstateProjectsView.tsx
+++ b/src/pages/views/realestate/RealEstateProjectsView.tsx
@@ -165,6 +165,7 @@ export const RealEstateProjectsView: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
+ apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
actionButtons={[
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []),
...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx
index bb5875a..eb4a176 100644
--- a/src/pages/views/trustee/TrusteePositionsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionsView.tsx
@@ -21,7 +21,7 @@ import { FormGeneratorForm } from '../../../components/FormGenerator/FormGenerat
import { FaSync, FaDownload } from 'react-icons/fa';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
-import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
+import { syncPositionsToAccounting } from '../../../api/trusteeApi';
import { formatAmount, formatPercent } from '../../../utils/formatAmount';
import styles from '../../admin/Admin.module.css';
@@ -35,7 +35,6 @@ export const TrusteePositionsView: React.FC = () => {
const { request } = useApiRequest();
const { showError, showSuccess } = useToast();
const [downloadingDocIds, setDownloadingDocIds] = useState>(new Set());
- const [syncStatusItems, setSyncStatusItems] = useState([]);
const [syncingPositionIds, setSyncingPositionIds] = useState>(new Set());
// Entity hook
@@ -71,25 +70,6 @@ export const TrusteePositionsView: React.FC = () => {
}
}, [instanceId]);
- // Load sync status for Sync-Status column
- useEffect(() => {
- if (!instanceId) return;
- let cancelled = false;
- fetchSyncStatus(request, instanceId)
- .then((data) => {
- if (!cancelled && data?.items) setSyncStatusItems(data.items);
- })
- .catch(() => {});
- return () => { cancelled = true; };
- }, [instanceId, request]);
-
- const _reloadSyncStatus = useCallback(() => {
- if (!instanceId) return;
- fetchSyncStatus(request, instanceId)
- .then((data) => data?.items && setSyncStatusItems(data.items))
- .catch(() => {});
- }, [instanceId, request]);
-
const handleBatchSyncToAccounting = useCallback(
async (rows: TrusteePosition[]) => {
if (!instanceId || rows.length === 0) return;
@@ -105,8 +85,8 @@ export const TrusteePositionsView: React.FC = () => {
const firstError = res.results?.find((r: any) => !r.success);
showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`);
}
+ // Refetch positions — the route now serves syncStatus inline.
refetch();
- _reloadSyncStatus();
} catch (err: any) {
showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.');
} finally {
@@ -117,7 +97,7 @@ export const TrusteePositionsView: React.FC = () => {
});
}
},
- [instanceId, request, refetch, _reloadSyncStatus, showSuccess, showError]
+ [instanceId, request, refetch, showSuccess, showError]
);
const handleSingleSyncToAccounting = useCallback(
@@ -220,55 +200,11 @@ export const TrusteePositionsView: React.FC = () => {
},
}), [handleDownloadDocument, downloadingDocIds]);
- // Map positionId -> display sync status: prefer synced over error (successful retry hides old error)
- const syncByPosition = useMemo(() => {
- const m = new Map();
- for (const s of syncStatusItems) {
- const cur = m.get(s.positionId);
- const prefer =
- !cur ||
- s.syncStatus === 'synced' ||
- (cur.syncStatus !== 'synced' && s.syncStatus === 'error');
- if (prefer) m.set(s.positionId, { syncStatus: s.syncStatus, errorMessage: s.errorMessage });
- }
- return m;
- }, [syncStatusItems]);
-
- const syncStatusColumn: ColumnConfig = useMemo(
- () => ({
- key: '_syncStatus',
- label: t('Synchronisierungsstatus'),
- sortable: false,
- filterable: false,
- searchable: false,
- width: 160,
- minWidth: 100,
- maxWidth: 280,
- formatter: (_value: unknown, row: TrusteePosition) => {
- const info = syncByPosition.get(row.id);
- if (!info)
- return — ;
- if (info.syncStatus === 'error')
- return (
-
- Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''}
-
- );
- if (info.syncStatus === 'synced')
- return Synchronisiert ;
- return {info.syncStatus} ;
- },
- }),
- [syncByPosition]
- );
-
const positionColumnOrder = [
'sysCreatedAt',
'_documentRefs',
- '_syncStatus',
+ 'syncStatus',
+ 'syncErrorMessage',
'valuta',
'tags',
'company',
@@ -304,7 +240,7 @@ export const TrusteePositionsView: React.FC = () => {
return col;
});
- const allColumns = [...attrColumns, belegeColumn, syncStatusColumn];
+ const allColumns = [...attrColumns, belegeColumn];
const byKey = new Map(allColumns.map(c => [c.key, c]));
const ordered: typeof allColumns = [];
@@ -321,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => {
if (col) ordered.push(col);
}
return resolveColumnTypes(ordered, attributes || []);
- }, [attributes, belegeColumn, syncStatusColumn]);
+ }, [attributes, belegeColumn]);
// Check permissions
const canCreate = permissions?.create !== 'n';
diff --git a/src/utils/columnTypeResolver.ts b/src/utils/columnTypeResolver.ts
index 7ce5e22..0c40a33 100644
--- a/src/utils/columnTypeResolver.ts
+++ b/src/utils/columnTypeResolver.ts
@@ -12,17 +12,26 @@ import type { AttributeType } from './attributeTypeMapper';
export interface AttributeLike {
name: string;
type?: string;
+ label?: string;
displayField?: string;
frontendFormat?: string;
frontendFormatLabels?: string[];
+ /** Backend-provided options. Accepts the canonical `{value,label}` array
+ * (preferred), a legacy plain `string[]` (treated as value==label), or a
+ * string reference (e.g. `"user.role"`) — only the array forms are merged
+ * into `ColumnConfig.options`; references are ignored here. */
+ options?: Array<{ value: string | number; label: string }> | string[] | string;
}
/**
- * Merge backend attribute types into page-defined column configs.
+ * Merge backend attribute metadata 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).
+ * For each column, the following fields are resolved from the matching backend
+ * attribute (by `key === attr.name`) when the column does not already define
+ * them: `type`, `label`, `displayField`, `frontendFormat`, `frontendFormatLabels`.
+ *
+ * Pages must NOT hardcode display data (labels, value translations) — they
+ * declare which columns to show, the backend declares the metadata.
*/
export function resolveColumnTypes(
columns: ColumnConfig[],
@@ -43,6 +52,9 @@ export function resolveColumnTypes(
if (attr.type) {
merged.type = attr.type as AttributeType;
}
+ if (attr.label && !col.label) {
+ merged.label = attr.label;
+ }
if (attr.displayField && !col.displayField) {
merged.displayField = attr.displayField;
}
@@ -52,6 +64,14 @@ export function resolveColumnTypes(
if (attr.frontendFormatLabels && !col.frontendFormatLabels) {
merged.frontendFormatLabels = attr.frontendFormatLabels;
}
+ if (Array.isArray(attr.options) && !col.options) {
+ // Normalise legacy `string[]` to the canonical `{value,label}` shape.
+ const opts = attr.options as Array;
+ const normalised = opts.map((o) =>
+ typeof o === 'string' ? { value: o, label: o } : o,
+ );
+ merged.options = normalised;
+ }
return merged;
});
}