Graph and data class falignment strict

This commit is contained in:
ValueOn AG 2026-04-26 22:53:39 +02:00
parent 8679cdffcb
commit f0e73b62d2
19 changed files with 223 additions and 282 deletions

View file

@ -156,8 +156,8 @@ export interface Automation2Workflow {
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** From PowerOnModel base — record creation timestamp (seconds) */
sysCreatedAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
}

View file

@ -48,7 +48,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
return list;
}, [workflows, search]);
@ -85,7 +85,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
const ts = wf.lastStartedAt || wf.sysCreatedAt;
return (
<div
key={wf.id}

View file

@ -10,7 +10,7 @@ import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
export interface FilterableField {
key: string;
label: string;
label?: string;
type?: AttributeType;
filterable?: boolean;
filterOptions?: string[];

View file

@ -106,7 +106,9 @@ const _objectToDisplayString = (value: Record<string, unknown>): 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<T = any> {
@ -1219,9 +1226,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}, [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<T extends Record<string, any>>({
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<T extends Record<string, any>>({
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<T extends Record<string, any>>({
<span
className={styles.columnLabel}
onClick={() => column.sortable && handleSort(column.key)}
title={column.label}
title={column.label ?? column.key}
>
{column.label}
{column.label ?? column.key}
</span>
</div>
@ -2298,7 +2319,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onClick={(e) => e.stopPropagation()}
>
<div className={styles.filterDropdownHeader}>
<span>{t('Filter')}: {column.label}</span>
<span>{t('Filter')}: {column.label ?? column.key}</span>
{column.key in filters && (
<button
className={styles.filterClearBtn}
@ -2448,7 +2469,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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)
}
/>
)}
</>

View file

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

View file

@ -110,6 +110,11 @@ export interface AttributeDefinition {
interface TrusteeEntityConfig<T> {
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<any>;
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
@ -138,7 +143,8 @@ function _createTrusteeEntityHook<T extends { id: string }>(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<TrusteePosition> = {
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,

View file

@ -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<string, string> = 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) => (
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
{_STATUS_LABELS[v] || v}
</span>
),
},
{
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<AttributeDefinition[]>([]);
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={[
{

View file

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

View file

@ -375,12 +375,20 @@ const OrphansTab: React.FC = () => {
const [downloading, setDownloading] = useState<string | null>(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<string>('');
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')}
</label>
</div>
<div className={styles.filterGroup}>
<label
className={styles.checkboxLabel}
title={t('FK-Referenzen auf UserInDB.id ausblenden — diese werden über den User-Purge-Workflow separat behandelt.')}
>
<input
type="checkbox"
checked={excludeUserFks}
onChange={e => setExcludeUserFks(e.target.checked)}
/>
{t('Ohne FK-Referenzen zu UserInDB.id')}
</label>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}

View file

@ -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 (
<span title={emailSent ? t('E-Mail wurde gesendet') : t('E-Mail nicht gesendet')}>
{emailText} {emailSent && '✓'}
</span>
);
},
},
{ 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 (
<span style={{ color: isExpired ? 'var(--danger-color)' : 'inherit' }}>
{text} {isExpired && '(abgelaufen)'}
</span>
);
},
},
{ 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.')}
</p>
{showUrlModal.email && (
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
{showUrlModal.emailSent
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSentFlag ? 'var(--success-color)' : 'var(--text-secondary)' }}>
{showUrlModal.emailSentFlag
? `${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
</p>

View file

@ -367,6 +367,45 @@ export const AdminLanguagesPage: React.FC = () => {
});
}, [rows, search]);
const _fetchFilterValues = useCallback(
async (columnKey: string, crossFilters?: Record<string, any>): 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<string>();
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',

View file

@ -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 (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> {t('System-Template')}
</span>
);
}
if (value === 'global') {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> {t('Template')}
</span>
);
}
return (
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
</span>
);
},
},
], [t]);
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
], []);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),

View file

@ -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')}
</span>
</td>
<td style={{ padding: '8px' }}>{r.emailSent ? t('Ja') : t('—')}</td>
<td style={{ padding: '8px' }}>{r.emailSentFlag ? t('Ja') : t('—')}</td>
</tr>
))}
</tbody>

View file

@ -74,9 +74,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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 ? (
<span style={{ color: 'var(--primary-color, #007bff)', fontWeight: 600 }}>{t('Ja')}</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>
),
},
{
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',

View file

@ -70,9 +70,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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 ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>Ja</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
key: 'isRunning',
label: t('läuft'),
width: 80,
formatter: (value: boolean) =>
value ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{ 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',

View file

@ -179,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
actionButtons={[
...(canUpdate
? [

View file

@ -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) }] : []),

View file

@ -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<Set<string>>(new Set());
const [syncStatusItems, setSyncStatusItems] = useState<AccountingSyncStatus[]>([]);
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(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<string, { syncStatus: string; errorMessage?: string }>();
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 <span style={{ color: 'var(--text-secondary)' }}></span>;
if (info.syncStatus === 'error')
return (
<span
title={info.errorMessage || ''}
style={{ color: 'var(--error-color, #dc2626)' }}
>
Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''}
</span>
);
if (info.syncStatus === 'synced')
return <span style={{ color: 'var(--success-color, #16a34a)' }}>Synchronisiert</span>;
return <span>{info.syncStatus}</span>;
},
}),
[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';

View file

@ -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<string | { value: string | number; label: string }>;
const normalised = opts.map((o) =>
typeof o === 'string' ? { value: o, label: o } : o,
);
merged.options = normalised;
}
return merged;
});
}