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;
|
||||
/** 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
||||
actionButtons={[
|
||||
...(canUpdate
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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) }] : []),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue