Graph and data class falignment strict
This commit is contained in:
parent
8679cdffcb
commit
f0e73b62d2
19 changed files with 223 additions and 282 deletions
|
|
@ -156,8 +156,8 @@ export interface Automation2Workflow {
|
||||||
stuckAtNodeId?: string;
|
stuckAtNodeId?: string;
|
||||||
/** Enriched: human-readable label for stuck node */
|
/** Enriched: human-readable label for stuck node */
|
||||||
stuckAtNodeLabel?: string;
|
stuckAtNodeLabel?: string;
|
||||||
/** Enriched: created timestamp (seconds) */
|
/** From PowerOnModel base — record creation timestamp (seconds) */
|
||||||
createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
/** Enriched: last run started timestamp (seconds) */
|
/** Enriched: last run started timestamp (seconds) */
|
||||||
lastStartedAt?: number;
|
lastStartedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||||
const list = q
|
const list = q
|
||||||
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||||
: [...workflows];
|
: [...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;
|
return list;
|
||||||
}, [workflows, search]);
|
}, [workflows, search]);
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||||
) : (
|
) : (
|
||||||
filtered.map((wf) => {
|
filtered.map((wf) => {
|
||||||
const isActive = wf.id === currentWorkflowId;
|
const isActive = wf.id === currentWorkflowId;
|
||||||
const ts = wf.lastStartedAt || wf.createdAt;
|
const ts = wf.lastStartedAt || wf.sysCreatedAt;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={wf.id}
|
key={wf.id}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||||
// Generic field/column config interface
|
// Generic field/column config interface
|
||||||
export interface FilterableField {
|
export interface FilterableField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label?: string;
|
||||||
type?: AttributeType;
|
type?: AttributeType;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
filterOptions?: string[];
|
filterOptions?: string[];
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,9 @@ const _objectToDisplayString = (value: Record<string, unknown>): string => {
|
||||||
// Types for the FormGeneratorTable
|
// Types for the FormGeneratorTable
|
||||||
export interface ColumnConfig {
|
export interface ColumnConfig {
|
||||||
key: string;
|
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;
|
type?: AttributeType;
|
||||||
width?: number;
|
width?: number;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
|
|
@ -129,6 +131,11 @@ export interface ColumnConfig {
|
||||||
// Pre-translated label tokens for binary/categorical cells, e.g. ["Ja", "-", "Nein"].
|
// 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.
|
// Resolved server-side via i18n so the FE never needs another translation hop.
|
||||||
frontendFormatLabels?: string[];
|
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> {
|
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]);
|
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]);
|
||||||
|
|
||||||
// Get unique values for a column (for filter dropdown)
|
// Get unique values for a column (for filter dropdown)
|
||||||
// Sources: 1) column.filterOptions (static enum)
|
// Sources: 1) column.filterOptions (static enum, page-defined)
|
||||||
// 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
|
// 2) column.options (backend-provided frontend_options on Pydantic field)
|
||||||
// 3) data — ONLY when no backend pagination (data = full dataset)
|
// 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
|
// With backend pagination, data is a single page, so extracting filter
|
||||||
// values from it would be incomplete and misleading.
|
// values from it would be incomplete and misleading.
|
||||||
const getUniqueValuesForColumn = useCallback((columnKey: string): FilterValue[] => {
|
const getUniqueValuesForColumn = useCallback((columnKey: string): FilterValue[] => {
|
||||||
|
|
@ -1231,6 +1239,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return column.filterOptions;
|
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
|
// displayField + local full dataset: { value, label } from enriched rows
|
||||||
if (column?.displayField && !supportsBackendPagination) {
|
if (column?.displayField && !supportsBackendPagination) {
|
||||||
const showKey = column.displayField;
|
const showKey = column.displayField;
|
||||||
|
|
@ -1905,6 +1917,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return renderBooleanCell(value, column, row);
|
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
|
// 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
|
// Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
|
||||||
const isId = isIdField(column.key);
|
const isId = isIdField(column.key);
|
||||||
|
|
@ -2284,9 +2305,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
<span
|
<span
|
||||||
className={styles.columnLabel}
|
className={styles.columnLabel}
|
||||||
onClick={() => column.sortable && handleSort(column.key)}
|
onClick={() => column.sortable && handleSort(column.key)}
|
||||||
title={column.label}
|
title={column.label ?? column.key}
|
||||||
>
|
>
|
||||||
{column.label}
|
{column.label ?? column.key}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2298,7 +2319,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className={styles.filterDropdownHeader}>
|
<div className={styles.filterDropdownHeader}>
|
||||||
<span>{t('Filter')}: {column.label}</span>
|
<span>{t('Filter')}: {column.label ?? column.key}</span>
|
||||||
{column.key in filters && (
|
{column.key in filters && (
|
||||||
<button
|
<button
|
||||||
className={styles.filterClearBtn}
|
className={styles.filterClearBtn}
|
||||||
|
|
@ -2448,7 +2469,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
allValues={columnFilterValues}
|
allValues={columnFilterValues}
|
||||||
activeFilter={filters[column.key]}
|
activeFilter={filters[column.key]}
|
||||||
onSelect={(value) => handleFilter(column.key, value)}
|
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)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,11 @@ export interface Invitation {
|
||||||
maxUses: number;
|
maxUses: number;
|
||||||
currentUses: number;
|
currentUses: number;
|
||||||
inviteUrl: string;
|
inviteUrl: string;
|
||||||
emailSent?: boolean;
|
// Backend-driven flags (computed @ Pydantic model + view enrichment)
|
||||||
isExpired?: boolean;
|
emailSentFlag?: boolean;
|
||||||
isUsedUp?: boolean;
|
emailSentAt?: number;
|
||||||
|
expiredFlag?: boolean;
|
||||||
|
usedUpFlag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvitationCreate {
|
export interface InvitationCreate {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,11 @@ export interface AttributeDefinition {
|
||||||
|
|
||||||
interface TrusteeEntityConfig<T> {
|
interface TrusteeEntityConfig<T> {
|
||||||
entityName: string;
|
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>;
|
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
|
||||||
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
|
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
|
||||||
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
|
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 [];
|
if (!instanceId) return [];
|
||||||
|
|
||||||
try {
|
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[] = [];
|
let attrs: AttributeDefinition[] = [];
|
||||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
attrs = response.data.attributes;
|
attrs = response.data.attributes;
|
||||||
|
|
@ -571,6 +577,9 @@ export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documen
|
||||||
|
|
||||||
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
|
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
|
||||||
entityName: '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,
|
fetchAll: fetchPositionsApi,
|
||||||
fetchById: fetchPositionByIdApi,
|
fetchById: fetchPositionByIdApi,
|
||||||
create: createPositionApi,
|
create: createPositionApi,
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'AutoRun')
|
fetchAttributes(request, 'AutoRun')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => {});
|
.catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); });
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const _loadMetrics = useCallback(async () => {
|
const _loadMetrics = useCallback(async () => {
|
||||||
|
|
@ -531,15 +531,6 @@ const _DashboardTab: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [showError, t]);
|
}, [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(() => [
|
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'workflowLabel',
|
key: 'workflowLabel',
|
||||||
|
|
@ -564,35 +555,24 @@ const _DashboardTab: React.FC = () => {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
displayField: 'instanceLabel',
|
displayField: 'instanceLabel',
|
||||||
},
|
},
|
||||||
|
{ key: 'status', width: 110, sortable: true, filterable: true },
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'startedAt',
|
||||||
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',
|
|
||||||
label: t('Gestartet'),
|
label: t('Gestartet'),
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sysModifiedAt',
|
key: 'completedAt',
|
||||||
label: t('Beendet'),
|
label: t('Beendet'),
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
], [t, _STATUS_LABELS]);
|
], [t]);
|
||||||
|
|
||||||
const _runColumns = useMemo(
|
const _runColumns = useMemo(
|
||||||
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
||||||
|
|
@ -675,7 +655,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
|
||||||
apiEndpoint="/api/system/workflow-runs"
|
apiEndpoint="/api/system/workflow-runs"
|
||||||
customActions={[
|
customActions={[
|
||||||
{
|
{
|
||||||
|
|
@ -724,9 +704,9 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'Automation2Workflow')
|
fetchAttributes(request, 'Automation2WorkflowView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => {});
|
.catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); });
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const _load = useCallback(async (paginationParams?: any) => {
|
const _load = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -740,7 +720,7 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
if (activeFilter === 'active') params.active = true;
|
if (activeFilter === 'active') params.active = true;
|
||||||
if (activeFilter === 'inactive') params.active = false;
|
if (activeFilter === 'inactive') params.active = false;
|
||||||
|
|
||||||
const defaultSort = [{ field: 'createdAt', direction: 'desc' }];
|
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
||||||
const pag = {
|
const pag = {
|
||||||
page: effectiveParams?.page || 1,
|
page: effectiveParams?.page || 1,
|
||||||
pageSize: effectiveParams?.pageSize || 25,
|
pageSize: effectiveParams?.pageSize || 25,
|
||||||
|
|
@ -929,24 +909,31 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
key: 'isRunning',
|
key: 'isRunning',
|
||||||
label: t('Läuft'),
|
label: t('Läuft'),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sysCreatedAt',
|
key: 'sysCreatedAt',
|
||||||
label: t('Erstellt'),
|
label: t('Erstellt'),
|
||||||
width: 140,
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'lastStartedAt',
|
key: 'lastStartedAt',
|
||||||
label: t('Zuletzt gestartet'),
|
label: t('Zuletzt gestartet'),
|
||||||
width: 160,
|
width: 160,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'runCount',
|
key: 'runCount',
|
||||||
label: t('Läufe'),
|
label: t('Läufe'),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
@ -1000,7 +987,7 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
initialSort={[{ key: 'createdAt', direction: 'desc' }]}
|
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||||
apiEndpoint="/api/system/workflow-runs/workflows"
|
apiEndpoint="/api/system/workflow-runs/workflows"
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -498,7 +498,6 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
width: 80,
|
width: 80,
|
||||||
formatter: (val: any) => (val ? t('OK') : t('Fehler')),
|
|
||||||
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
|
||||||
|
|
@ -375,12 +375,20 @@ const OrphansTab: React.FC = () => {
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
const [cleaningAll, setCleaningAll] = useState(false);
|
const [cleaningAll, setCleaningAll] = useState(false);
|
||||||
const [onlyProblems, setOnlyProblems] = useState(true);
|
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 [dbFilter, setDbFilter] = useState<string>('');
|
||||||
|
|
||||||
const _fetchOrphans = useCallback(async () => {
|
const _fetchOrphans = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 res = await api.get(`/api/admin/database-health/orphans${params}`);
|
||||||
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
|
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
|
||||||
...o,
|
...o,
|
||||||
|
|
@ -392,7 +400,7 @@ const OrphansTab: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dbFilter]);
|
}, [dbFilter, excludeUserFks]);
|
||||||
|
|
||||||
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
|
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
|
||||||
|
|
||||||
|
|
@ -515,7 +523,7 @@ const OrphansTab: React.FC = () => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setCleaningAll(true);
|
setCleaningAll(true);
|
||||||
try {
|
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 results: CleanResult[] = res.data.results || [];
|
||||||
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
||||||
const errors = results.filter(r => r.error);
|
const errors = results.filter(r => r.error);
|
||||||
|
|
@ -636,6 +644,19 @@ const OrphansTab: React.FC = () => {
|
||||||
{t('Nur Probleme')}
|
{t('Nur Probleme')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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}>
|
<div className={styles.headerActions}>
|
||||||
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
|
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
||||||
|
|
||||||
// Format timestamp
|
// Format timestamp (used by URL modal only).
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
if (!timestamp) return '-';
|
if (!timestamp) return '-';
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
|
|
@ -87,33 +87,12 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{
|
{ key: 'targetUsername', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||||
key: 'targetUsername',
|
{ key: 'email', sortable: true, filterable: true, width: 180 },
|
||||||
label: t('Benutzername'),
|
{ key: 'emailSentFlag', sortable: true, filterable: true, width: 90 },
|
||||||
sortable: true,
|
{ key: 'emailSentAt', sortable: true, filterable: true, width: 150 },
|
||||||
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: 'roleIds',
|
key: 'roleIds',
|
||||||
label: t('Rollen'),
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
width: 150,
|
width: 150,
|
||||||
|
|
@ -125,36 +104,18 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{ key: 'expiresAt', sortable: true, filterable: true, width: 150 },
|
||||||
key: 'expiresAt',
|
{ key: 'expiredFlag', sortable: true, filterable: true, width: 90 },
|
||||||
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: 'currentUses',
|
key: 'currentUses',
|
||||||
label: t('Verwendet'),
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
width: 100,
|
width: 100,
|
||||||
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
|
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
|
||||||
},
|
},
|
||||||
{
|
{ key: 'usedUpFlag', sortable: true, filterable: true, width: 90 },
|
||||||
key: 'sysCreatedAt',
|
{ key: 'sysCreatedAt', sortable: true, filterable: true, width: 150 },
|
||||||
label: t('Erstellt'),
|
], [roles]);
|
||||||
sortable: true,
|
|
||||||
width: 150,
|
|
||||||
formatter: (value: number) => formatDate(value),
|
|
||||||
},
|
|
||||||
], [roles, t]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
|
@ -445,8 +406,8 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{t('verwendet werden.')}
|
{t('verwendet werden.')}
|
||||||
</p>
|
</p>
|
||||||
{showUrlModal.email && (
|
{showUrlModal.email && (
|
||||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSentFlag ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||||||
{showUrlModal.emailSent
|
{showUrlModal.emailSentFlag
|
||||||
? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
|
? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
|
||||||
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
|
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,45 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
}, [rows, search]);
|
}, [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 columns = useMemo(() => {
|
||||||
const raw: ColumnConfig[] = [
|
const raw: ColumnConfig[] = [
|
||||||
{ key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
|
{ key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 },
|
||||||
|
|
@ -886,6 +925,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
pagination={false}
|
pagination={false}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
|
hookData={_hookData}
|
||||||
customActions={[
|
customActions={[
|
||||||
{
|
{
|
||||||
id: 'sync-xx',
|
id: 'sync-xx',
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type Pagi
|
||||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { fetchAttributes } from '../../api/attributesApi';
|
import { fetchAttributes } from '../../api/attributesApi';
|
||||||
|
|
@ -72,7 +72,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMandates();
|
loadMandates();
|
||||||
fetchAttributes(request, 'Role')
|
fetchAttributes(request, 'RoleView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => setBackendAttributes([]));
|
.catch(() => setBackendAttributes([]));
|
||||||
}, [fetchMandates, request]);
|
}, [fetchMandates, request]);
|
||||||
|
|
@ -105,51 +105,17 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{
|
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||||
key: 'roleLabel',
|
|
||||||
label: t('Bezeichnung'),
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
label: t('Beschreibung'),
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
width: 250,
|
width: 250,
|
||||||
formatter: (value: string) => getDescriptionText(value),
|
formatter: (value: string) => getDescriptionText(value),
|
||||||
},
|
},
|
||||||
{
|
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
|
||||||
key: 'scopeType',
|
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
|
||||||
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]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ interface DispatchResult {
|
||||||
username?: string;
|
username?: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
emailSent?: boolean;
|
emailSentFlag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -254,7 +254,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
email: emailTrim,
|
email: emailTrim,
|
||||||
username: inv.username,
|
username: inv.username,
|
||||||
success: true,
|
success: true,
|
||||||
emailSent: result.data?.emailSent,
|
emailSentFlag: result.data?.emailSentFlag,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
results.push({
|
results.push({
|
||||||
|
|
@ -731,7 +731,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
{r.success ? t('Erfolgreich') : r.error || t('Fehler')}
|
{r.success ? t('Erfolgreich') : r.error || t('Fehler')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px' }}>{r.emailSent ? t('Ja') : t('—')}</td>
|
<td style={{ padding: '8px' }}>{r.emailSentFlag ? t('Ja') : t('—')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,11 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'AutoWorkflow')
|
fetchAttributes(request, 'Automation2WorkflowView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => {});
|
.catch((err) => {
|
||||||
|
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
||||||
|
});
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const load = useCallback(async (paginationParams?: any) => {
|
const load = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -185,38 +187,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(
|
const _rawColumns: ColumnConfig[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'label', label: t('Vorlage'), width: 220, sortable: true },
|
{ key: 'label', label: t('Vorlage'), width: 220, sortable: true, filterable: true },
|
||||||
{
|
{ key: 'templateScope', width: 100, sortable: true, filterable: true },
|
||||||
key: 'templateScope',
|
{ key: 'sharedReadOnly', width: 100, sortable: true, filterable: true },
|
||||||
label: t('Bereich'),
|
{ key: 'sysCreatedBy', width: 140, sortable: true, filterable: true, displayField: 'sysCreatedByLabel' },
|
||||||
width: 100,
|
{ key: 'sysCreatedAt', width: 140, sortable: true, filterable: true, formatter: (v: number) => _formatTs(v) },
|
||||||
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),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[t, scopeLabels],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
|
|
@ -274,6 +251,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
apiEndpoint={`/api/workflows/${instanceId}/templates`}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'Automation2Workflow')
|
fetchAttributes(request, 'Automation2WorkflowView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => {});
|
.catch((err) => {
|
||||||
|
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
||||||
|
});
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const load = useCallback(async (paginationParams?: any) => {
|
const load = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -262,54 +264,42 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'label', label: t('Workflow'), width: 200, sortable: true },
|
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
||||||
{
|
{ key: 'active', width: 80, sortable: true, filterable: true },
|
||||||
key: 'active',
|
{ key: 'isRunning', width: 80, sortable: true, filterable: true },
|
||||||
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: 'stuckAtNodeLabel',
|
key: 'stuckAtNodeLabel',
|
||||||
label: t('steht bei'),
|
label: t('steht bei'),
|
||||||
width: 160,
|
width: 160,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
formatter: (value: string, row: Automation2Workflow) =>
|
formatter: (value: string, row: Automation2Workflow) =>
|
||||||
row.isRunning && (value || row.stuckAtNodeId)
|
row.isRunning && (value || row.stuckAtNodeId)
|
||||||
? value || row.stuckAtNodeId || '—'
|
? value || row.stuckAtNodeId || '—'
|
||||||
: '—',
|
: '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'createdAt',
|
key: 'sysCreatedAt',
|
||||||
label: t('Erstellt'),
|
label: t('Erstellt'),
|
||||||
width: 140,
|
width: 140,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => formatTs(v),
|
formatter: (v: number) => formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'lastStartedAt',
|
key: 'lastStartedAt',
|
||||||
label: t('zuletzt gestartet'),
|
label: t('zuletzt gestartet'),
|
||||||
width: 160,
|
width: 160,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => formatTs(v),
|
formatter: (v: number) => formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'runCount',
|
key: 'runCount',
|
||||||
label: t('Läufe'),
|
label: t('Läufe'),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
formatter: (v: number) => (v != null ? String(v) : '0'),
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
@ -390,6 +380,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
apiEndpoint={`/api/workflows/${instanceId}/workflows`}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
...(canUpdate
|
...(canUpdate
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []),
|
...(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) }] : []),
|
...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { FormGeneratorForm } from '../../../components/FormGenerator/FormGenerat
|
||||||
import { FaSync, FaDownload } from 'react-icons/fa';
|
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
import { syncPositionsToAccounting } from '../../../api/trusteeApi';
|
||||||
import { formatAmount, formatPercent } from '../../../utils/formatAmount';
|
import { formatAmount, formatPercent } from '../../../utils/formatAmount';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -35,7 +35,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
const [downloadingDocIds, setDownloadingDocIds] = useState<Set<string>>(new Set());
|
const [downloadingDocIds, setDownloadingDocIds] = useState<Set<string>>(new Set());
|
||||||
const [syncStatusItems, setSyncStatusItems] = useState<AccountingSyncStatus[]>([]);
|
|
||||||
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(new Set());
|
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Entity hook
|
// Entity hook
|
||||||
|
|
@ -71,25 +70,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [instanceId]);
|
}, [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(
|
const handleBatchSyncToAccounting = useCallback(
|
||||||
async (rows: TrusteePosition[]) => {
|
async (rows: TrusteePosition[]) => {
|
||||||
if (!instanceId || rows.length === 0) return;
|
if (!instanceId || rows.length === 0) return;
|
||||||
|
|
@ -105,8 +85,8 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
const firstError = res.results?.find((r: any) => !r.success);
|
const firstError = res.results?.find((r: any) => !r.success);
|
||||||
showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`);
|
showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`);
|
||||||
}
|
}
|
||||||
|
// Refetch positions — the route now serves syncStatus inline.
|
||||||
refetch();
|
refetch();
|
||||||
_reloadSyncStatus();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.');
|
showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -117,7 +97,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[instanceId, request, refetch, _reloadSyncStatus, showSuccess, showError]
|
[instanceId, request, refetch, showSuccess, showError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSingleSyncToAccounting = useCallback(
|
const handleSingleSyncToAccounting = useCallback(
|
||||||
|
|
@ -220,55 +200,11 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
},
|
},
|
||||||
}), [handleDownloadDocument, downloadingDocIds]);
|
}), [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 = [
|
const positionColumnOrder = [
|
||||||
'sysCreatedAt',
|
'sysCreatedAt',
|
||||||
'_documentRefs',
|
'_documentRefs',
|
||||||
'_syncStatus',
|
'syncStatus',
|
||||||
|
'syncErrorMessage',
|
||||||
'valuta',
|
'valuta',
|
||||||
'tags',
|
'tags',
|
||||||
'company',
|
'company',
|
||||||
|
|
@ -304,7 +240,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
return col;
|
return col;
|
||||||
});
|
});
|
||||||
|
|
||||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn];
|
const allColumns = [...attrColumns, belegeColumn];
|
||||||
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
||||||
|
|
||||||
const ordered: typeof allColumns = [];
|
const ordered: typeof allColumns = [];
|
||||||
|
|
@ -321,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
if (col) ordered.push(col);
|
if (col) ordered.push(col);
|
||||||
}
|
}
|
||||||
return resolveColumnTypes(ordered, attributes || []);
|
return resolveColumnTypes(ordered, attributes || []);
|
||||||
}, [attributes, belegeColumn, syncStatusColumn]);
|
}, [attributes, belegeColumn]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,26 @@ import type { AttributeType } from './attributeTypeMapper';
|
||||||
export interface AttributeLike {
|
export interface AttributeLike {
|
||||||
name: string;
|
name: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
label?: string;
|
||||||
displayField?: string;
|
displayField?: string;
|
||||||
frontendFormat?: string;
|
frontendFormat?: string;
|
||||||
frontendFormatLabels?: 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
|
* For each column, the following fields are resolved from the matching backend
|
||||||
* (by `key === attr.name`). Page-level overrides for `type` are preserved only
|
* attribute (by `key === attr.name`) when the column does not already define
|
||||||
* when no backend attribute is available (graceful degradation during loading).
|
* 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(
|
export function resolveColumnTypes(
|
||||||
columns: ColumnConfig[],
|
columns: ColumnConfig[],
|
||||||
|
|
@ -43,6 +52,9 @@ export function resolveColumnTypes(
|
||||||
if (attr.type) {
|
if (attr.type) {
|
||||||
merged.type = attr.type as AttributeType;
|
merged.type = attr.type as AttributeType;
|
||||||
}
|
}
|
||||||
|
if (attr.label && !col.label) {
|
||||||
|
merged.label = attr.label;
|
||||||
|
}
|
||||||
if (attr.displayField && !col.displayField) {
|
if (attr.displayField && !col.displayField) {
|
||||||
merged.displayField = attr.displayField;
|
merged.displayField = attr.displayField;
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +64,14 @@ export function resolveColumnTypes(
|
||||||
if (attr.frontendFormatLabels && !col.frontendFormatLabels) {
|
if (attr.frontendFormatLabels && !col.frontendFormatLabels) {
|
||||||
merged.frontendFormatLabels = attr.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;
|
return merged;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue