frontend_nyla/src/components/UnifiedDataBar/SourcesTab.tsx
2026-04-11 19:44:52 +02:00

1617 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SourcesTab Full data-source management inside the Unified Data Bar.
*
* Tree structure (Browse Sources):
* UserConnection (Level 1, loaded on mount)
* └─ Service (Level 2, loaded when connection expanded)
* └─ Folder / Site / File (Level 3+, loaded when service/folder expanded)
*
* Feature Data tree:
* MandateGroup
* └─ FeatureConnection (feature instance)
* └─ FeatureTable (tables exposed by that instance)
*
* Active Sources sections show scope-cycling and neutralize-toggle buttons.
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/* ─── Types (inline, no external imports) ────────────────────────────── */
interface UdbDataSource {
id: string;
connectionId: string;
sourceType: string;
path: string;
label: string;
displayPath?: string;
scope: string;
neutralize: boolean;
}
interface UdbFeatureDataSource {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
objectKey: string;
label: string;
scope: string;
neutralize: boolean;
recordFilter?: Record<string, string>;
}
interface TreeNode {
key: string;
label: string;
icon: string;
type: 'connection' | 'service' | 'folder' | 'file';
expanded: boolean;
loading: boolean;
children: TreeNode[] | null;
connectionId: string;
service?: string;
path?: string;
displayPath?: string;
authority?: string;
}
interface FeatureConnectionNode {
featureInstanceId: string;
featureCode: string;
mandateId?: string;
label: string;
icon: string;
tableCount: number;
expanded: boolean;
loading: boolean;
tables: FeatureTableNode[] | null;
parentRecords: Record<string, ParentRecordNode[]>;
}
interface MandateGroupNode {
mandateId: string;
mandateLabel: string;
expanded: boolean;
featureConnections: FeatureConnectionNode[];
}
interface FeatureTableNode {
objectKey: string;
tableName: string;
label: string;
fields: string[];
isParent?: boolean;
parentTable?: string;
parentKey?: string;
displayFields?: string[];
}
interface ParentRecordNode {
id: string;
displayLabel: string;
fields: Record<string, any>;
tableName: string;
expanded: boolean;
}
/* ─── Props ──────────────────────────────────────────────────────────── */
interface SourcesTabProps {
context: UdbContext;
onSourcesChanged?: () => void;
}
/* ─── Icons ──────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};
const _SERVICE_ICONS: Record<string, string> = {
sharepoint: '\uD83D\uDCC1',
onedrive: '\u2601\uFE0F',
outlook: '\uD83D\uDCE7',
teams: '\uD83D\uDCAC',
drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2',
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
const _SOURCE_COLORS: Record<string, string> = {
sharepointFolder: '#0078d4',
sharepoint: '#0078d4',
onedriveFolder: '#0078d4',
onedrive: '#0078d4',
outlookFolder: '#0078d4',
outlook: '#0078d4',
googleDriveFolder: '#34a853',
drive: '#34a853',
gmailFolder: '#ea4335',
gmail: '#ea4335',
ftpFolder: '#795548',
files: '#795548',
'local:ftp': '#795548',
'local:jira': '#0052CC',
clickup: '#7b68ee',
};
function _getSourceColor(sourceType: string): string {
return _SOURCE_COLORS[sourceType] || '#F25843';
}
const _SOURCE_ICONS: Record<string, string> = {
sharepointFolder: '\uD83D\uDCC1',
sharepoint: '\uD83D\uDCC1',
onedriveFolder: '\u2601\uFE0F',
onedrive: '\u2601\uFE0F',
outlookFolder: '\uD83D\uDCE7',
outlook: '\uD83D\uDCE7',
googleDriveFolder: '\uD83D\uDCC2',
drive: '\uD83D\uDCC2',
gmailFolder: '\uD83D\uDCE8',
gmail: '\uD83D\uDCE8',
ftpFolder: '\uD83D\uDD17',
files: '\uD83D\uDD17',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
clickup: '\uD83D\uDCCB',
};
function _getSourceIcon(sourceType: string): string {
return _SOURCE_ICONS[sourceType] || '\uD83D\uDCC1';
}
/* ─── Scope / Neutralize constants ───────────────────────────────────── */
const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
function _nextScope(current: string): string {
const idx = _SCOPE_ORDER.indexOf(current);
if (idx === -1) return _SCOPE_ORDER[0];
return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length];
}
const _SERVICE_TO_SOURCE_TYPE: Record<string, string> = {
sharepoint: 'sharepointFolder',
onedrive: 'onedriveFolder',
outlook: 'outlookFolder',
drive: 'googleDriveFolder',
gmail: 'gmailFolder',
files: 'ftpFolder',
};
/* ─── Tree helpers ───────────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}
function _mapFeatureTreeUpdate(
prev: MandateGroupNode[],
featureInstanceId: string,
updater: (n: FeatureConnectionNode) => FeatureConnectionNode,
): MandateGroupNode[] {
return prev.map(g => ({
...g,
featureConnections: g.featureConnections.map(n =>
n.featureInstanceId === featureInstanceId ? updater(n) : n
),
}));
}
function _findFeatureInstanceMeta(
groups: MandateGroupNode[],
featureInstanceId: string,
): { mandateLabel: string; instanceLabel: string } | null {
for (const g of groups) {
const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId);
if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label };
}
return null;
}
function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string {
const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || '';
return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
}
function _featureDataSourceHoverTitle(
meta: { mandateLabel: string; instanceLabel: string } | null,
fds: UdbFeatureDataSource,
): string {
const parts: string[] = [];
if (meta) {
parts.push(meta.mandateLabel, meta.instanceLabel);
}
const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName
? `${fds.label} (${fds.tableName})`
: (fds.label || fds.tableName);
parts.push(labelPart);
if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) {
parts.push(fds.objectKey);
}
return parts.join(' / ');
}
/* ─── Data fetching (module-level) ───────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
const services = res.data.services || [];
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
displayPath: s.label || s.service,
}));
}
async function _browseService(
instanceId: string,
connectionId: string,
service: string,
path: string,
parentDisplayPath: string | undefined,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => {
const seg = entry.name || '';
const displayPath = parentDisplayPath
? `${parentDisplayPath} / ${seg}`
: seg;
return {
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
displayPath,
};
});
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Spinner (inline) ───────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid var(--border-color, #ccc)', borderTopColor: 'var(--primary-color, #F25843)',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Component ──────────────────────────────────────────────────────── */
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) => {
const { t } = useLanguage();
const _scopeLabel = (scope: string) => ({
personal: t('Persönlich'),
featureInstance: t('Feature-Instanz'),
mandate: t('Mandant'),
global: t('Global'),
} as Record<string, string>)[scope] || scope;
const _scopeCycleTitle = (scope: string) =>
`${t('Bereich')}: ${_scopeLabel(scope)}${_scopeLabel(_nextScope(scope))}`;
const instanceId = context.instanceId;
/* ── Active sources (fetched internally) ── */
const [dataSources, setDataSources] = useState<UdbDataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);
/* ── Browse tree state ── */
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null);
/* ── Feature tree state ── */
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
/* ── Fetch active personal data sources ── */
const _fetchDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
.then(res => {
if (!mountedRef.current) return;
const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({
id: d.id,
connectionId: d.connectionId,
sourceType: d.sourceType,
path: d.path,
label: d.label,
displayPath: d.displayPath,
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
}));
setDataSources(list);
})
.catch(() => { if (mountedRef.current) setDataSources([]); });
}, [instanceId]);
/* ── Fetch active feature data sources ── */
const _fetchFeatureDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/feature-datasources`)
.then(res => {
if (!mountedRef.current) return;
const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({
id: d.id,
featureInstanceId: d.featureInstanceId,
featureCode: d.featureCode,
tableName: d.tableName,
objectKey: d.objectKey,
label: d.label,
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
recordFilter: d.recordFilter || undefined,
}));
setFeatureDataSources(list);
})
.catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
}, [instanceId]);
useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]);
useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]);
/* ── Load Level 1: UserConnections ── */
const _loadConnections = useCallback(() => {
if (!instanceId) return;
setLoadingRoot(true);
api.get(`/api/workspace/${instanceId}/connections`)
.then(res => {
if (!mountedRef.current) return;
const conns = res.data.connections || [];
const nodes: TreeNode[] = conns
.filter((c: any) => c.status === 'active')
.map((c: any) => ({
key: `conn-${c.id}`,
label: c.externalEmail || c.externalUsername || c.authority,
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
type: 'connection' as const,
expanded: false,
loading: false,
children: null,
connectionId: c.id,
authority: c.authority,
}));
setTree(nodes);
})
.catch(() => { if (mountedRef.current) setTree([]); })
.finally(() => { if (mountedRef.current) setLoadingRoot(false); });
}, [instanceId]);
useEffect(() => { _loadConnections(); }, [_loadConnections]);
/* ── Generic tree update helper ── */
const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => {
setTree(prev => _mapTree(prev, key, updater));
}, []);
/* ── Toggle expand/collapse ── */
const _toggleNode = useCallback(async (node: TreeNode) => {
if (node.expanded) {
_updateNode(node.key, n => ({ ...n, expanded: false }));
return;
}
if (node.children !== null) {
_updateNode(node.key, n => ({ ...n, expanded: true }));
return;
}
_updateNode(node.key, n => ({ ...n, loading: true, expanded: true }));
try {
let children: TreeNode[] = [];
if (node.type === 'connection') {
children = await _loadServices(instanceId, node.connectionId);
} else if (node.type === 'service' || node.type === 'folder') {
children = await _browseService(
instanceId,
node.connectionId,
node.service!,
node.path || '/',
node.displayPath || node.label,
);
}
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children }));
}
} catch {
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children: [] }));
}
}
}, [instanceId, _updateNode]);
/* ── Add as DataSource ── */
const _addAsDataSource = useCallback(async (node: TreeNode) => {
if (!node.service || !node.connectionId) return;
setAddingPath(node.key);
try {
await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId,
sourceType: _SERVICE_TO_SOURCE_TYPE[node.service] || node.service,
path: node.path || '/',
label: node.label,
displayPath: node.displayPath || node.label,
});
_fetchDataSources();
onSourcesChanged?.();
} catch (err) {
console.error('Failed to add data source:', err);
} finally {
if (mountedRef.current) setAddingPath(null);
}
}, [instanceId, _fetchDataSources]);
/* ── Remove DataSource ── */
const _removeDatasource = useCallback(async (dsId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
_fetchDataSources();
onSourcesChanged?.();
} catch (err) {
console.error('Failed to remove data source:', err);
}
}, [instanceId, _fetchDataSources]);
/* ── Check if a path is already added ── */
const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => {
const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined;
return dataSources.some(ds =>
ds.connectionId === connectionId &&
ds.path === (path || '/') &&
(!expectedSourceType || ds.sourceType === expectedSourceType),
);
}, [dataSources]);
/* ── Scope change (personal data source, optimistic) ── */
const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
const newScope = _nextScope(ds.scope);
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
try {
await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
}
}, []);
/* ── Neutralize toggle (personal data source, optimistic) ── */
const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
const newValue = !ds.neutralize;
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
try {
await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
}
}, []);
/* ── Scope change (feature data source, optimistic) ── */
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
const newScope = _nextScope(fds.scope);
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
try {
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
} catch {
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
}
}, []);
/* ── Neutralize toggle (feature data source, optimistic) ── */
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
const newValue = !fds.neutralize;
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
try {
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
} catch {
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
}
}, []);
/* ── Feature Connections: Load Level 1 ── */
const _loadFeatureConnections = useCallback(() => {
if (!instanceId) return;
setLoadingFeatures(true);
api.get(`/api/workspace/${instanceId}/feature-connections`)
.then(res => {
if (!mountedRef.current) return;
const groups = res.data.featureConnectionsByMandate || [];
setFeatureTree(groups.map((g: any) => ({
mandateId: g.mandateId,
mandateLabel: g.mandateLabel || g.mandateId,
expanded: true,
featureConnections: (g.featureConnections || []).map((c: any) => ({
featureInstanceId: c.featureInstanceId,
featureCode: c.featureCode,
mandateId: c.mandateId,
label: c.label,
icon: c.icon || '\uD83D\uDDC3\uFE0F',
tableCount: c.tableCount || 0,
expanded: false,
loading: false,
tables: null,
parentRecords: {},
})),
})));
})
.catch(() => { if (mountedRef.current) setFeatureTree([]); })
.finally(() => { if (mountedRef.current) setLoadingFeatures(false); });
}, [instanceId]);
useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]);
/* ── Feature Connections: Toggle mandate group ── */
const _toggleMandateGroup = useCallback((mandateId: string) => {
setFeatureTree(prev => prev.map(g =>
g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g
));
}, []);
/* ── Feature Connections: Toggle expand ── */
const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => {
if (node.expanded) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false })));
return;
}
if (node.tables !== null) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true })));
return;
}
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: true, expanded: true,
})));
try {
const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`);
const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({
objectKey: t.objectKey,
tableName: t.tableName,
label: t.label || {},
fields: t.fields || [],
isParent: t.isParent || false,
parentTable: t.parentTable || undefined,
parentKey: t.parentKey || undefined,
displayFields: t.displayFields || undefined,
}));
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: false, tables,
})));
}
} catch {
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n, loading: false, tables: [],
})));
}
}
}, [instanceId]);
/* ── Feature: Add table as FeatureDataSource ── */
const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => {
const key = `${node.featureInstanceId}-${table.tableName}`;
setAddingFeatureKey(key);
try {
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
tableName: table.tableName,
objectKey: table.objectKey,
label: table.label || table.tableName,
});
_fetchFeatureDataSources();
onSourcesChanged?.();
} catch (err) {
console.error('Failed to add feature data source:', err);
} finally {
if (mountedRef.current) setAddingFeatureKey(null);
}
}, [instanceId, _fetchFeatureDataSources]);
/* ── Feature: Remove FeatureDataSource ── */
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
_fetchFeatureDataSources();
onSourcesChanged?.();
} catch (err) {
console.error('Failed to remove feature data source:', err);
}
}, [instanceId, _fetchFeatureDataSources]);
/* ── Feature: check if table already added ── */
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
return featureDataSources.some(fds =>
fds.featureInstanceId === featureInstanceId && fds.tableName === tableName,
);
}, [featureDataSources]);
/* ── Parent groups: expand/collapse + load records ── */
const [expandedParentGroups, setExpandedParentGroups] = useState<Set<string>>(new Set());
const [loadingParentGroup, setLoadingParentGroup] = useState<string | null>(null);
const [addingParentKey, setAddingParentKey] = useState<string | null>(null);
const _toggleParentGroup = useCallback(async (node: FeatureConnectionNode, parentTableName: string) => {
const groupKey = `${node.featureInstanceId}-${parentTableName}`;
if (expandedParentGroups.has(groupKey)) {
setExpandedParentGroups(prev => { const next = new Set(prev); next.delete(groupKey); return next; });
return;
}
setExpandedParentGroups(prev => new Set(prev).add(groupKey));
if (node.parentRecords[parentTableName]) return;
setLoadingParentGroup(groupKey);
try {
const res = await api.get(
`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${parentTableName}`,
);
const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({
id: r.id,
displayLabel: r.displayLabel || r.id,
fields: r.fields || {},
tableName: parentTableName,
expanded: false,
}));
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n,
parentRecords: { ...n.parentRecords, [parentTableName]: records },
})));
}
} catch {
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
...n,
parentRecords: { ...n.parentRecords, [parentTableName]: [] },
})));
}
} finally {
if (mountedRef.current) setLoadingParentGroup(null);
}
}, [instanceId, expandedParentGroups]);
const _toggleParentRecord = useCallback((featureInstanceId: string, parentTableName: string, recordId: string) => {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, featureInstanceId, n => ({
...n,
parentRecords: {
...n.parentRecords,
[parentTableName]: (n.parentRecords[parentTableName] || []).map(r =>
r.id === recordId ? { ...r, expanded: !r.expanded } : r,
),
},
})));
}, []);
/* ── Parent record: add parent + all children with recordFilter ── */
const _addParentRecord = useCallback(async (
node: FeatureConnectionNode,
parentRecord: ParentRecordNode,
allTables: FeatureTableNode[],
) => {
const addKey = `${node.featureInstanceId}-parent-${parentRecord.id}`;
setAddingParentKey(addKey);
try {
const parentTable = allTables.find(t => t.tableName === parentRecord.tableName && t.isParent);
const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName);
if (parentTable) {
const parentLabel = `${parentTable.label || parentTable.tableName}: ${parentRecord.displayLabel}`;
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
tableName: parentTable.tableName,
objectKey: parentTable.objectKey,
label: parentLabel,
recordFilter: { id: parentRecord.id },
});
}
for (const child of childTables) {
const childLabel = `${child.label || child.tableName}: ${parentRecord.displayLabel}`;
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
tableName: child.tableName,
objectKey: child.objectKey,
label: childLabel,
recordFilter: { [child.parentKey!]: parentRecord.id },
});
}
_fetchFeatureDataSources();
onSourcesChanged?.();
} catch (err) {
console.error('Failed to add parent record sources:', err);
} finally {
if (mountedRef.current) setAddingParentKey(null);
}
}, [instanceId, _fetchFeatureDataSources]);
/* ── Check if a parent record is already added ── */
const _isParentRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => {
return featureDataSources.some(fds =>
fds.featureInstanceId === featureInstanceId &&
fds.tableName === parentTableName &&
fds.recordFilter?.id === recordId,
);
}, [featureDataSources]);
/* ── Render ── */
return (
<div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
{/* ── Active Personal Sources ── */}
{dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
{t('Aktive persönliche Quellen')}
</div>
{[...dataSources].sort((a, b) => {
const aKey = `${a.sourceType}|${a.label || a.path || ''}`;
const bKey = `${b.sourceType}|${b.label || b.path || ''}`;
return aKey.localeCompare(bKey);
}).map(ds => {
const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId;
const folder = ds.label || ds.path || ds.id;
return (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`,
fontSize: 12,
}} title={_personalDataSourceHoverTitle(connLabel, ds)}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{connLabel} {folder}
</span>
<button
onClick={() => _cyclePersonalScope(ds)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
}}
title={_scopeCycleTitle(ds.scope)}
>
{_SCOPE_ICONS[ds.scope] || _SCOPE_ICONS.personal}
</button>
<button
onClick={() => _togglePersonalNeutralize(ds)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
opacity: ds.neutralize ? 1 : 0.35,
}}
title={ds.neutralize ? t('Neutralisierung: Klick zum Deaktivieren') : t('Neutralisierung aus: Klick zum Aktivieren')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
</div>
);
})}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* ── Browse Sources header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
{t('Quellen durchsuchen')}
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--primary-color, #F25843)' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* ── Browse Sources tree ── */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Verbindungen werden geladen…')}
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Keine aktiven Verbindungen.')}
</div>
)}
{tree.map(node => (
<_TreeNodeView
key={node.key}
node={node}
depth={0}
onToggle={_toggleNode}
onAdd={_addAsDataSource}
isAdded={_isAdded}
addingPath={addingPath}
/>
))}
{/* ── Divider ── */}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
{/* ── Active Feature Sources (grouped by parent record) ── */}
{featureDataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
{t('Aktive Feature-Quellen')}
</div>
{(() => {
const sorted = [...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || ''));
const grouped: { key: string; label: string; items: UdbFeatureDataSource[] }[] = [];
const standalone: UdbFeatureDataSource[] = [];
for (const fds of sorted) {
if (fds.recordFilter && Object.keys(fds.recordFilter).length > 0) {
const filterKey = `${fds.featureInstanceId}|${JSON.stringify(fds.recordFilter)}`;
let group = grouped.find(g => g.key === filterKey);
if (!group) {
const parentLabel = fds.label.includes(':') ? fds.label.split(':')[1]?.trim() : fds.label;
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
group = { key: filterKey, label: `${meta?.instanceLabel || fds.featureCode} ${parentLabel}`, items: [] };
grouped.push(group);
}
group.items.push(fds);
} else {
standalone.push(fds);
}
}
return (
<>
{grouped.map(group => (
<div key={group.key} style={{ marginBottom: 4 }}>
<div style={{
fontSize: 11, fontWeight: 600, color: '#7b1fa2',
padding: '2px 6px', marginBottom: 1,
display: 'flex', alignItems: 'center', gap: 4,
}}>
<span>{'\uD83D\uDCCB'}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.label}
</span>
<button
onClick={() => { group.items.forEach(fds => _removeFeatureDataSource(fds.id)); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Alle Tabellen für diese Quelle entfernen')}
>
{'\u2715'}
</button>
</div>
{group.items.map(fds => {
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
return (
<div key={fds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '3px 6px 3px 22px', borderRadius: 4, marginBottom: 1,
background: '#7b1fa210',
fontSize: 11,
}} title={_featureDataSourceHoverTitle(meta, fds)}>
<span style={{ fontSize: 11, flexShrink: 0, color: '#7b1fa2' }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDCC4'}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.tableName}
</span>
<button
onClick={() => _cycleFeatureScope(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }}
title={_scopeCycleTitle(fds.scope)}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
<button
onClick={() => _toggleFeatureNeutralize(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
</div>
);
})}
</div>
))}
{standalone.map(fds => {
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
return (
<div key={fds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: '#7b1fa218',
borderLeft: '3px solid #7b1fa2',
fontSize: 12,
}} title={_featureDataSourceHoverTitle(meta, fds)}>
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fdsConnLabel} {fds.tableName}
</span>
<button
onClick={() => _cycleFeatureScope(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1 }}
title={_scopeCycleTitle(fds.scope)}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
<button
onClick={() => _toggleFeatureNeutralize(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
</div>
);
})}
</>
);
})()}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* ── Feature Data header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
{t('Feature-Daten')}
</span>
<button
onClick={_loadFeatureConnections}
disabled={loadingFeatures}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
>
{loadingFeatures ? '...' : '\u21BB'}
</button>
</div>
{/* ── Feature Data tree ── */}
{loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Feature-Instanzen werden geladen…')}
</div>
)}
{!loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Keine Feature-Instanzen gefunden.')}
</div>
)}
{featureTree.map(g => (
<_MandateGroupView
key={g.mandateId}
group={g}
onToggleGroup={_toggleMandateGroup}
onToggleFeature={_toggleFeatureNode}
onAddTable={_addFeatureTable}
isTableAdded={_isFeatureTableAdded}
addingKey={addingFeatureKey}
onToggleParentGroup={_toggleParentGroup}
onToggleParentRecord={_toggleParentRecord}
onAddParentRecord={_addParentRecord}
isParentRecordAdded={_isParentRecordAdded}
expandedParentGroups={expandedParentGroups}
loadingParentGroup={loadingParentGroup}
addingParentKey={addingParentKey}
/>
))}
</div>
);
};
/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */
interface _TreeNodeViewProps {
node: TreeNode;
depth: number;
onToggle: (node: TreeNode) => void;
onAdd: (node: TreeNode) => void;
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
addingPath: string | null;
}
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const hasChildren = node.type !== 'file';
const chevron = hasChildren
? (node.expanded ? '\u25BE' : '\u25B8')
: '\u00A0\u00A0';
const canAdd = node.type === 'folder' || node.type === 'service';
const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path);
const isAdding = addingPath === node.key;
return (
<div>
<div
onClick={() => { if (hasChildren) onToggle(node); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: depth * 16 + 4,
paddingRight: 4,
paddingTop: 3,
paddingBottom: 3,
cursor: hasChildren ? 'pointer' : 'default',
borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s',
userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: node.type === 'connection' ? 600 : 400,
}}>
{node.label}
</span>
{canAdd && hovered && !alreadyAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid var(--primary-color, #F25843)', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: 'var(--primary-color, #F25843)', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title={t('Als Datenquelle hinzufügen')}
>
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('Bereits hinzugefügt')}>
{'\u2713'}
</span>
)}
</div>
{node.expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => (
<_TreeNodeView
key={child.key}
node={child}
depth={depth + 1}
onToggle={onToggle}
onAdd={onAdd}
isAdded={isAdded}
addingPath={addingPath}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
{t('(leer)')}
</div>
)}
</div>
);
};
/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */
interface _MandateGroupViewProps {
group: MandateGroupNode;
onToggleGroup: (mandateId: string) => void;
onToggleFeature: (node: FeatureConnectionNode) => void;
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void;
onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void;
onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void;
isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean;
expandedParentGroups: Set<string>;
loadingParentGroup: string | null;
addingParentKey: string | null;
}
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
expandedParentGroups, loadingParentGroup, addingParentKey,
}) => {
const [hovered, setHovered] = useState(false);
const chevron = group.expanded ? '\u25BE' : '\u25B8';
return (
<div>
<div
onClick={() => onToggleGroup(group.mandateId)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700, color: '#555' }}>
{group.mandateLabel}
</span>
</div>
{group.expanded && (
<div style={{ paddingLeft: 10 }}>
{group.featureConnections.map(fNode => (
<_FeatureNodeView
key={fNode.featureInstanceId}
node={fNode}
onToggle={onToggleFeature}
onAddTable={onAddTable}
isTableAdded={isTableAdded}
addingKey={addingKey}
onToggleParentGroup={onToggleParentGroup}
onToggleParentRecord={onToggleParentRecord}
onAddParentRecord={onAddParentRecord}
isParentRecordAdded={isParentRecordAdded}
expandedParentGroups={expandedParentGroups}
loadingParentGroup={loadingParentGroup}
addingParentKey={addingParentKey}
/>
))}
</div>
)}
</div>
);
};
/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */
interface _FeatureNodeViewProps {
node: FeatureConnectionNode;
onToggle: (node: FeatureConnectionNode) => void;
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void;
onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void;
onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void;
isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean;
expandedParentGroups: Set<string>;
loadingParentGroup: string | null;
addingParentKey: string | null;
}
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
node, onToggle, onAddTable, isTableAdded, addingKey,
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
expandedParentGroups, loadingParentGroup, addingParentKey,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = node.expanded ? '\u25BE' : '\u25B8';
const parentTables = (node.tables || []).filter(t => t.isParent);
const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable);
return (
<div>
<div
onClick={() => onToggle(node)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
{node.label}
</span>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} {t('Tabellen')}
</span>
</div>
{node.expanded && node.tables && node.tables.length > 0 && (
<div>
{/* Parent table groups (hierarchical) */}
{parentTables.map(pt => {
const groupKey = `${node.featureInstanceId}-${pt.tableName}`;
const isGroupExpanded = expandedParentGroups.has(groupKey);
const isGroupLoading = loadingParentGroup === groupKey;
const records = node.parentRecords[pt.tableName];
const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName);
const ptLabel = pt.label || pt.tableName;
return (
<_ParentGroupView
key={groupKey}
featureNode={node}
parentTable={pt}
label={ptLabel}
expanded={isGroupExpanded}
loading={isGroupLoading}
records={records || null}
childTables={childTables}
allTables={node.tables!}
onToggleGroup={() => onToggleParentGroup(node, pt.tableName)}
onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)}
onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)}
isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)}
addingParentKey={addingParentKey}
/>
);
})}
{/* Standalone tables (not part of any hierarchy) */}
{standaloneTables.map(table => (
<_FeatureTableRow
key={table.objectKey}
featureNode={node}
table={table}
onAdd={onAddTable}
isAdded={isTableAdded(node.featureInstanceId, table.tableName)}
isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`}
/>
))}
</div>
)}
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
{t('(keine Tabellen)')}
</div>
)}
</div>
);
};
/* ─── FeatureTableRow ────────────────────────────────────────────────── */
interface _FeatureTableRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isAdded: boolean;
isAdding: boolean;
}
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureNode, table, onAdd, isAdded, isAdding,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const tableLabel = table.label || table.tableName;
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
title={`${table.tableName}: ${table.fields.join(', ')}`}
>
<span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{tableLabel}
</span>
{hovered && !isAdded && (
<button
onClick={() => onAdd(featureNode, table)}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title={t('Als Feature-Datenquelle hinzufügen')}
>
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('Bereits hinzugefügt')}>
{'\u2713'}
</span>
)}
</div>
);
};
/* ─── ParentGroupView (parent table → parent records) ────────────────── */
interface _ParentGroupViewProps {
featureNode: FeatureConnectionNode;
parentTable: FeatureTableNode;
label: string;
expanded: boolean;
loading: boolean;
records: ParentRecordNode[] | null;
childTables: FeatureTableNode[];
allTables: FeatureTableNode[];
onToggleGroup: () => void;
onToggleRecord: (recordId: string) => void;
onAddRecord: (record: ParentRecordNode) => void;
isRecordAdded: (recordId: string) => boolean;
addingParentKey: string | null;
}
const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables,
onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = expanded ? '\u25BE' : '\u25B8';
return (
<div>
<div
onClick={onToggleGroup}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 24, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCC2'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600, color: '#555' }}>
{label}
</span>
{childTables.length > 0 && (
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
+{childTables.length} {t('Tabellen')}
</span>
)}
</div>
{expanded && records && records.length > 0 && (
<div>
{records.map(record => (
<_ParentRecordRow
key={record.id}
featureNode={featureNode}
record={record}
childTables={childTables}
allTables={allTables}
onToggle={() => onToggleRecord(record.id)}
onAdd={() => onAddRecord(record)}
isAdded={isRecordAdded(record.id)}
isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`}
/>
))}
</div>
)}
{expanded && records && records.length === 0 && !loading && (
<div style={{ paddingLeft: 52, fontSize: 11, color: '#bbb', padding: '2px 0 2px 52px' }}>
{t('(keine Einträge)')}
</div>
)}
</div>
);
};
/* ─── ParentRecordRow (single parent record + child tables info) ─────── */
interface _ParentRecordRowProps {
featureNode: FeatureConnectionNode;
record: ParentRecordNode;
childTables: FeatureTableNode[];
allTables: FeatureTableNode[];
onToggle: () => void;
onAdd: () => void;
isAdded: boolean;
isAdding: boolean;
}
const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
featureNode: _featureNode, record, childTables, allTables: _allTables,
onToggle, onAdd, isAdded, isAdding,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = record.expanded ? '\u25BE' : '\u25B8';
return (
<div>
<div
onClick={onToggle}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s', userSelect: 'none',
}}
title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCCB'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{record.displayLabel}
</span>
{hovered && !isAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title={t('Alle Tabellen für diese Quelle hinzufügen')}
>
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('Bereits hinzugefügt')}>
{'\u2713'}
</span>
)}
</div>
{record.expanded && (
<div style={{ paddingLeft: 64 }}>
{childTables.map(ct => {
const ctLabel = ct.label || ct.tableName;
return (
<div key={ct.objectKey} style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingTop: 2, paddingBottom: 2, fontSize: 11, color: '#888',
}}>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC4'}</span>
<span>{ctLabel}</span>
<span style={{ fontSize: 10, color: '#bbb' }}>({ct.parentKey})</span>
</div>
);
})}
</div>
)}
</div>
);
};
export default SourcesTab;